diff --git a/build.sbt b/build.sbt index e6f5e40..227e292 100644 --- a/build.sbt +++ b/build.sbt @@ -14,38 +14,38 @@ lazy val proof = entityProject("proof", file("domain/proof")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .repo(_.dependsOn(`mongo-support`)) .entity(_.dependsOn(`akka-persistence-support`)) .projection(_.dependsOn(`akka-persistence-support`)) - .endpoints(_.dependsOn(`tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val parameters = entityProject("parameters", file("domain/parameters")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) - .endpoints(_.dependsOn(`tapir-support`)) + .codecs(_.dependsOn(codecs, `tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val users = entityProject("users", file("domain/users")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val core = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("core")) -lazy val json = crossProject(JSPlatform, JVMPlatform) +lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) - .in(file("json")) + .in(file("codecs")) .settings(IWDeps.zioJson) - .dependsOn(core) + .dependsOn(core, `tapir-support`) lazy val endpoints = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("endpoints")) - .dependsOn(core, json, `tapir-support`) + .dependsOn(core, codecs, `tapir-support`) // TODO: move all from fiftyforms to iterative works lazy val ui = (project in file("fiftyforms/ui")) @@ -80,7 +80,11 @@ lazy val `akka-persistence-support` = project .in(file("fiftyforms/akka-persistence")) - .settings(IWDeps.akka.profiles.eventsourcedJdbcProjection) + .settings( + IWDeps.akka.libs.persistence, + libraryDependencies += "com.typesafe.akka" %% "akka-cluster-sharding-typed" % IWDeps.akka.V, + IWDeps.akka.profiles.eventsourcedJdbcProjection + ) lazy val app = (project in file("app")) .enablePlugins(ScalaJSPlugin, VitePlugin) @@ -166,6 +170,7 @@ proof.query.api, proof.command.api, proof.query.projection, + proof.command.entity, endpoints.jvm ) diff --git a/build.sbt b/build.sbt index e6f5e40..227e292 100644 --- a/build.sbt +++ b/build.sbt @@ -14,38 +14,38 @@ lazy val proof = entityProject("proof", file("domain/proof")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .repo(_.dependsOn(`mongo-support`)) .entity(_.dependsOn(`akka-persistence-support`)) .projection(_.dependsOn(`akka-persistence-support`)) - .endpoints(_.dependsOn(`tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val parameters = entityProject("parameters", file("domain/parameters")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) - .endpoints(_.dependsOn(`tapir-support`)) + .codecs(_.dependsOn(codecs, `tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val users = entityProject("users", file("domain/users")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val core = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("core")) -lazy val json = crossProject(JSPlatform, JVMPlatform) +lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) - .in(file("json")) + .in(file("codecs")) .settings(IWDeps.zioJson) - .dependsOn(core) + .dependsOn(core, `tapir-support`) lazy val endpoints = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("endpoints")) - .dependsOn(core, json, `tapir-support`) + .dependsOn(core, codecs, `tapir-support`) // TODO: move all from fiftyforms to iterative works lazy val ui = (project in file("fiftyforms/ui")) @@ -80,7 +80,11 @@ lazy val `akka-persistence-support` = project .in(file("fiftyforms/akka-persistence")) - .settings(IWDeps.akka.profiles.eventsourcedJdbcProjection) + .settings( + IWDeps.akka.libs.persistence, + libraryDependencies += "com.typesafe.akka" %% "akka-cluster-sharding-typed" % IWDeps.akka.V, + IWDeps.akka.profiles.eventsourcedJdbcProjection + ) lazy val app = (project in file("app")) .enablePlugins(ScalaJSPlugin, VitePlugin) @@ -166,6 +170,7 @@ proof.query.api, proof.command.api, proof.query.projection, + proof.command.entity, endpoints.jvm ) diff --git a/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala new file mode 100644 index 0000000..22ebfb8 --- /dev/null +++ b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala @@ -0,0 +1,26 @@ +package mdr.pdb +package codecs + +import zio.json.* +import fiftyforms.tapir.CustomTapir + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + given JsonCodec[WhoWhen] = DeriveJsonCodec.gen + given JsonCodec[OsobniCislo] = + JsonCodec.string.transform(OsobniCislo.apply, _.toString) + given JsonFieldEncoder[OsobniCislo] = + JsonFieldEncoder.string.contramap(_.toString) + given JsonFieldDecoder[OsobniCislo] = + JsonFieldDecoder.string.map(OsobniCislo(_)) + +trait TapirCodecs extends CustomTapir: + given Schema[OsobniCislo] = Schema.string + given Codec.PlainCodec[OsobniCislo] = + Codec.string.mapDecode(OsobniCislo.apply andThen DecodeResult.Value.apply)( + _.toString + ) + given Schema[WhoWhen] = Schema.derived + +object Codecs extends Codecs diff --git a/build.sbt b/build.sbt index e6f5e40..227e292 100644 --- a/build.sbt +++ b/build.sbt @@ -14,38 +14,38 @@ lazy val proof = entityProject("proof", file("domain/proof")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .repo(_.dependsOn(`mongo-support`)) .entity(_.dependsOn(`akka-persistence-support`)) .projection(_.dependsOn(`akka-persistence-support`)) - .endpoints(_.dependsOn(`tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val parameters = entityProject("parameters", file("domain/parameters")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) - .endpoints(_.dependsOn(`tapir-support`)) + .codecs(_.dependsOn(codecs, `tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val users = entityProject("users", file("domain/users")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val core = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("core")) -lazy val json = crossProject(JSPlatform, JVMPlatform) +lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) - .in(file("json")) + .in(file("codecs")) .settings(IWDeps.zioJson) - .dependsOn(core) + .dependsOn(core, `tapir-support`) lazy val endpoints = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("endpoints")) - .dependsOn(core, json, `tapir-support`) + .dependsOn(core, codecs, `tapir-support`) // TODO: move all from fiftyforms to iterative works lazy val ui = (project in file("fiftyforms/ui")) @@ -80,7 +80,11 @@ lazy val `akka-persistence-support` = project .in(file("fiftyforms/akka-persistence")) - .settings(IWDeps.akka.profiles.eventsourcedJdbcProjection) + .settings( + IWDeps.akka.libs.persistence, + libraryDependencies += "com.typesafe.akka" %% "akka-cluster-sharding-typed" % IWDeps.akka.V, + IWDeps.akka.profiles.eventsourcedJdbcProjection + ) lazy val app = (project in file("app")) .enablePlugins(ScalaJSPlugin, VitePlugin) @@ -166,6 +170,7 @@ proof.query.api, proof.command.api, proof.query.projection, + proof.command.entity, endpoints.jvm ) diff --git a/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala new file mode 100644 index 0000000..22ebfb8 --- /dev/null +++ b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala @@ -0,0 +1,26 @@ +package mdr.pdb +package codecs + +import zio.json.* +import fiftyforms.tapir.CustomTapir + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + given JsonCodec[WhoWhen] = DeriveJsonCodec.gen + given JsonCodec[OsobniCislo] = + JsonCodec.string.transform(OsobniCislo.apply, _.toString) + given JsonFieldEncoder[OsobniCislo] = + JsonFieldEncoder.string.contramap(_.toString) + given JsonFieldDecoder[OsobniCislo] = + JsonFieldDecoder.string.map(OsobniCislo(_)) + +trait TapirCodecs extends CustomTapir: + given Schema[OsobniCislo] = Schema.string + given Codec.PlainCodec[OsobniCislo] = + Codec.string.mapDecode(OsobniCislo.apply andThen DecodeResult.Value.apply)( + _.toString + ) + given Schema[WhoWhen] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/parameters/shared/codecs/src/Codecs.scala b/domain/parameters/shared/codecs/src/Codecs.scala new file mode 100644 index 0000000..751e79b --- /dev/null +++ b/domain/parameters/shared/codecs/src/Codecs.scala @@ -0,0 +1,10 @@ +package mdr.pdb +package parameters +package codecs + +import zio.json.* + +trait Codecs: + + given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen + given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/build.sbt b/build.sbt index e6f5e40..227e292 100644 --- a/build.sbt +++ b/build.sbt @@ -14,38 +14,38 @@ lazy val proof = entityProject("proof", file("domain/proof")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .repo(_.dependsOn(`mongo-support`)) .entity(_.dependsOn(`akka-persistence-support`)) .projection(_.dependsOn(`akka-persistence-support`)) - .endpoints(_.dependsOn(`tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val parameters = entityProject("parameters", file("domain/parameters")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) - .endpoints(_.dependsOn(`tapir-support`)) + .codecs(_.dependsOn(codecs, `tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val users = entityProject("users", file("domain/users")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val core = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("core")) -lazy val json = crossProject(JSPlatform, JVMPlatform) +lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) - .in(file("json")) + .in(file("codecs")) .settings(IWDeps.zioJson) - .dependsOn(core) + .dependsOn(core, `tapir-support`) lazy val endpoints = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("endpoints")) - .dependsOn(core, json, `tapir-support`) + .dependsOn(core, codecs, `tapir-support`) // TODO: move all from fiftyforms to iterative works lazy val ui = (project in file("fiftyforms/ui")) @@ -80,7 +80,11 @@ lazy val `akka-persistence-support` = project .in(file("fiftyforms/akka-persistence")) - .settings(IWDeps.akka.profiles.eventsourcedJdbcProjection) + .settings( + IWDeps.akka.libs.persistence, + libraryDependencies += "com.typesafe.akka" %% "akka-cluster-sharding-typed" % IWDeps.akka.V, + IWDeps.akka.profiles.eventsourcedJdbcProjection + ) lazy val app = (project in file("app")) .enablePlugins(ScalaJSPlugin, VitePlugin) @@ -166,6 +170,7 @@ proof.query.api, proof.command.api, proof.query.projection, + proof.command.entity, endpoints.jvm ) diff --git a/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala new file mode 100644 index 0000000..22ebfb8 --- /dev/null +++ b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala @@ -0,0 +1,26 @@ +package mdr.pdb +package codecs + +import zio.json.* +import fiftyforms.tapir.CustomTapir + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + given JsonCodec[WhoWhen] = DeriveJsonCodec.gen + given JsonCodec[OsobniCislo] = + JsonCodec.string.transform(OsobniCislo.apply, _.toString) + given JsonFieldEncoder[OsobniCislo] = + JsonFieldEncoder.string.contramap(_.toString) + given JsonFieldDecoder[OsobniCislo] = + JsonFieldDecoder.string.map(OsobniCislo(_)) + +trait TapirCodecs extends CustomTapir: + given Schema[OsobniCislo] = Schema.string + given Codec.PlainCodec[OsobniCislo] = + Codec.string.mapDecode(OsobniCislo.apply andThen DecodeResult.Value.apply)( + _.toString + ) + given Schema[WhoWhen] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/parameters/shared/codecs/src/Codecs.scala b/domain/parameters/shared/codecs/src/Codecs.scala new file mode 100644 index 0000000..751e79b --- /dev/null +++ b/domain/parameters/shared/codecs/src/Codecs.scala @@ -0,0 +1,10 @@ +package mdr.pdb +package parameters +package codecs + +import zio.json.* + +trait Codecs: + + given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen + given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/parameters/shared/json/src/Codecs.scala b/domain/parameters/shared/json/src/Codecs.scala deleted file mode 100644 index b849072..0000000 --- a/domain/parameters/shared/json/src/Codecs.scala +++ /dev/null @@ -1,10 +0,0 @@ -package mdr.pdb -package parameters -package json - -import zio.json.* - -trait Codecs: - - given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen - given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/build.sbt b/build.sbt index e6f5e40..227e292 100644 --- a/build.sbt +++ b/build.sbt @@ -14,38 +14,38 @@ lazy val proof = entityProject("proof", file("domain/proof")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .repo(_.dependsOn(`mongo-support`)) .entity(_.dependsOn(`akka-persistence-support`)) .projection(_.dependsOn(`akka-persistence-support`)) - .endpoints(_.dependsOn(`tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val parameters = entityProject("parameters", file("domain/parameters")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) - .endpoints(_.dependsOn(`tapir-support`)) + .codecs(_.dependsOn(codecs, `tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val users = entityProject("users", file("domain/users")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val core = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("core")) -lazy val json = crossProject(JSPlatform, JVMPlatform) +lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) - .in(file("json")) + .in(file("codecs")) .settings(IWDeps.zioJson) - .dependsOn(core) + .dependsOn(core, `tapir-support`) lazy val endpoints = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("endpoints")) - .dependsOn(core, json, `tapir-support`) + .dependsOn(core, codecs, `tapir-support`) // TODO: move all from fiftyforms to iterative works lazy val ui = (project in file("fiftyforms/ui")) @@ -80,7 +80,11 @@ lazy val `akka-persistence-support` = project .in(file("fiftyforms/akka-persistence")) - .settings(IWDeps.akka.profiles.eventsourcedJdbcProjection) + .settings( + IWDeps.akka.libs.persistence, + libraryDependencies += "com.typesafe.akka" %% "akka-cluster-sharding-typed" % IWDeps.akka.V, + IWDeps.akka.profiles.eventsourcedJdbcProjection + ) lazy val app = (project in file("app")) .enablePlugins(ScalaJSPlugin, VitePlugin) @@ -166,6 +170,7 @@ proof.query.api, proof.command.api, proof.query.projection, + proof.command.entity, endpoints.jvm ) diff --git a/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala new file mode 100644 index 0000000..22ebfb8 --- /dev/null +++ b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala @@ -0,0 +1,26 @@ +package mdr.pdb +package codecs + +import zio.json.* +import fiftyforms.tapir.CustomTapir + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + given JsonCodec[WhoWhen] = DeriveJsonCodec.gen + given JsonCodec[OsobniCislo] = + JsonCodec.string.transform(OsobniCislo.apply, _.toString) + given JsonFieldEncoder[OsobniCislo] = + JsonFieldEncoder.string.contramap(_.toString) + given JsonFieldDecoder[OsobniCislo] = + JsonFieldDecoder.string.map(OsobniCislo(_)) + +trait TapirCodecs extends CustomTapir: + given Schema[OsobniCislo] = Schema.string + given Codec.PlainCodec[OsobniCislo] = + Codec.string.mapDecode(OsobniCislo.apply andThen DecodeResult.Value.apply)( + _.toString + ) + given Schema[WhoWhen] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/parameters/shared/codecs/src/Codecs.scala b/domain/parameters/shared/codecs/src/Codecs.scala new file mode 100644 index 0000000..751e79b --- /dev/null +++ b/domain/parameters/shared/codecs/src/Codecs.scala @@ -0,0 +1,10 @@ +package mdr.pdb +package parameters +package codecs + +import zio.json.* + +trait Codecs: + + given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen + given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/parameters/shared/json/src/Codecs.scala b/domain/parameters/shared/json/src/Codecs.scala deleted file mode 100644 index b849072..0000000 --- a/domain/parameters/shared/json/src/Codecs.scala +++ /dev/null @@ -1,10 +0,0 @@ -package mdr.pdb -package parameters -package json - -import zio.json.* - -trait Codecs: - - given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen - given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/proof/command/api/src/ProofCommandApi.scala b/domain/proof/command/api/src/ProofCommandApi.scala new file mode 100644 index 0000000..12f747e --- /dev/null +++ b/domain/proof/command/api/src/ProofCommandApi.scala @@ -0,0 +1,16 @@ +package mdr.pdb.proof.command +package api + +import endpoints.Endpoints +import entity.ProofCommandBus +import fiftyforms.tapir.CustomTapir +import fiftyforms.tapir.InternalServerError + +object ProofCommandApi extends CustomTapir: + + val submitCommand: ZServerEndpoint[ProofCommandBus, Any] = + Endpoints.submitCommand.zServerLogic(cmd => + ProofCommandBus + .submitCommand(cmd) + .mapError(InternalServerError.fromThrowable) + ) diff --git a/build.sbt b/build.sbt index e6f5e40..227e292 100644 --- a/build.sbt +++ b/build.sbt @@ -14,38 +14,38 @@ lazy val proof = entityProject("proof", file("domain/proof")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .repo(_.dependsOn(`mongo-support`)) .entity(_.dependsOn(`akka-persistence-support`)) .projection(_.dependsOn(`akka-persistence-support`)) - .endpoints(_.dependsOn(`tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val parameters = entityProject("parameters", file("domain/parameters")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) - .endpoints(_.dependsOn(`tapir-support`)) + .codecs(_.dependsOn(codecs, `tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val users = entityProject("users", file("domain/users")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val core = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("core")) -lazy val json = crossProject(JSPlatform, JVMPlatform) +lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) - .in(file("json")) + .in(file("codecs")) .settings(IWDeps.zioJson) - .dependsOn(core) + .dependsOn(core, `tapir-support`) lazy val endpoints = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("endpoints")) - .dependsOn(core, json, `tapir-support`) + .dependsOn(core, codecs, `tapir-support`) // TODO: move all from fiftyforms to iterative works lazy val ui = (project in file("fiftyforms/ui")) @@ -80,7 +80,11 @@ lazy val `akka-persistence-support` = project .in(file("fiftyforms/akka-persistence")) - .settings(IWDeps.akka.profiles.eventsourcedJdbcProjection) + .settings( + IWDeps.akka.libs.persistence, + libraryDependencies += "com.typesafe.akka" %% "akka-cluster-sharding-typed" % IWDeps.akka.V, + IWDeps.akka.profiles.eventsourcedJdbcProjection + ) lazy val app = (project in file("app")) .enablePlugins(ScalaJSPlugin, VitePlugin) @@ -166,6 +170,7 @@ proof.query.api, proof.command.api, proof.query.projection, + proof.command.entity, endpoints.jvm ) diff --git a/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala new file mode 100644 index 0000000..22ebfb8 --- /dev/null +++ b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala @@ -0,0 +1,26 @@ +package mdr.pdb +package codecs + +import zio.json.* +import fiftyforms.tapir.CustomTapir + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + given JsonCodec[WhoWhen] = DeriveJsonCodec.gen + given JsonCodec[OsobniCislo] = + JsonCodec.string.transform(OsobniCislo.apply, _.toString) + given JsonFieldEncoder[OsobniCislo] = + JsonFieldEncoder.string.contramap(_.toString) + given JsonFieldDecoder[OsobniCislo] = + JsonFieldDecoder.string.map(OsobniCislo(_)) + +trait TapirCodecs extends CustomTapir: + given Schema[OsobniCislo] = Schema.string + given Codec.PlainCodec[OsobniCislo] = + Codec.string.mapDecode(OsobniCislo.apply andThen DecodeResult.Value.apply)( + _.toString + ) + given Schema[WhoWhen] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/parameters/shared/codecs/src/Codecs.scala b/domain/parameters/shared/codecs/src/Codecs.scala new file mode 100644 index 0000000..751e79b --- /dev/null +++ b/domain/parameters/shared/codecs/src/Codecs.scala @@ -0,0 +1,10 @@ +package mdr.pdb +package parameters +package codecs + +import zio.json.* + +trait Codecs: + + given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen + given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/parameters/shared/json/src/Codecs.scala b/domain/parameters/shared/json/src/Codecs.scala deleted file mode 100644 index b849072..0000000 --- a/domain/parameters/shared/json/src/Codecs.scala +++ /dev/null @@ -1,10 +0,0 @@ -package mdr.pdb -package parameters -package json - -import zio.json.* - -trait Codecs: - - given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen - given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/proof/command/api/src/ProofCommandApi.scala b/domain/proof/command/api/src/ProofCommandApi.scala new file mode 100644 index 0000000..12f747e --- /dev/null +++ b/domain/proof/command/api/src/ProofCommandApi.scala @@ -0,0 +1,16 @@ +package mdr.pdb.proof.command +package api + +import endpoints.Endpoints +import entity.ProofCommandBus +import fiftyforms.tapir.CustomTapir +import fiftyforms.tapir.InternalServerError + +object ProofCommandApi extends CustomTapir: + + val submitCommand: ZServerEndpoint[ProofCommandBus, Any] = + Endpoints.submitCommand.zServerLogic(cmd => + ProofCommandBus + .submitCommand(cmd) + .mapError(InternalServerError.fromThrowable) + ) diff --git a/domain/proof/command/codecs/src/Codecs.scala b/domain/proof/command/codecs/src/Codecs.scala new file mode 100644 index 0000000..0e75f82 --- /dev/null +++ b/domain/proof/command/codecs/src/Codecs.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof.command +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends proof.codecs.JsonCodecs: + + given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen + given JsonCodec[CreateProof] = DeriveJsonCodec.gen + given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen + given JsonCodec[UpdateProof] = DeriveJsonCodec.gen + given JsonCodec[RevokeProof] = DeriveJsonCodec.gen + given JsonCodec[Command] = DeriveJsonCodec.gen + +trait TapirCodecs extends proof.codecs.TapirCodecs: + + given Schema[AuthorizeOption] = Schema.derived + given Schema[CreateProof] = Schema.derived + given Schema[AuthorizeProof] = Schema.derived + given Schema[UpdateProof] = Schema.derived + given Schema[RevokeProof] = Schema.derived + given Schema[Command] = Schema.derived diff --git a/build.sbt b/build.sbt index e6f5e40..227e292 100644 --- a/build.sbt +++ b/build.sbt @@ -14,38 +14,38 @@ lazy val proof = entityProject("proof", file("domain/proof")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .repo(_.dependsOn(`mongo-support`)) .entity(_.dependsOn(`akka-persistence-support`)) .projection(_.dependsOn(`akka-persistence-support`)) - .endpoints(_.dependsOn(`tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val parameters = entityProject("parameters", file("domain/parameters")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) - .endpoints(_.dependsOn(`tapir-support`)) + .codecs(_.dependsOn(codecs, `tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val users = entityProject("users", file("domain/users")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val core = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("core")) -lazy val json = crossProject(JSPlatform, JVMPlatform) +lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) - .in(file("json")) + .in(file("codecs")) .settings(IWDeps.zioJson) - .dependsOn(core) + .dependsOn(core, `tapir-support`) lazy val endpoints = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("endpoints")) - .dependsOn(core, json, `tapir-support`) + .dependsOn(core, codecs, `tapir-support`) // TODO: move all from fiftyforms to iterative works lazy val ui = (project in file("fiftyforms/ui")) @@ -80,7 +80,11 @@ lazy val `akka-persistence-support` = project .in(file("fiftyforms/akka-persistence")) - .settings(IWDeps.akka.profiles.eventsourcedJdbcProjection) + .settings( + IWDeps.akka.libs.persistence, + libraryDependencies += "com.typesafe.akka" %% "akka-cluster-sharding-typed" % IWDeps.akka.V, + IWDeps.akka.profiles.eventsourcedJdbcProjection + ) lazy val app = (project in file("app")) .enablePlugins(ScalaJSPlugin, VitePlugin) @@ -166,6 +170,7 @@ proof.query.api, proof.command.api, proof.query.projection, + proof.command.entity, endpoints.jvm ) diff --git a/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala new file mode 100644 index 0000000..22ebfb8 --- /dev/null +++ b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala @@ -0,0 +1,26 @@ +package mdr.pdb +package codecs + +import zio.json.* +import fiftyforms.tapir.CustomTapir + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + given JsonCodec[WhoWhen] = DeriveJsonCodec.gen + given JsonCodec[OsobniCislo] = + JsonCodec.string.transform(OsobniCislo.apply, _.toString) + given JsonFieldEncoder[OsobniCislo] = + JsonFieldEncoder.string.contramap(_.toString) + given JsonFieldDecoder[OsobniCislo] = + JsonFieldDecoder.string.map(OsobniCislo(_)) + +trait TapirCodecs extends CustomTapir: + given Schema[OsobniCislo] = Schema.string + given Codec.PlainCodec[OsobniCislo] = + Codec.string.mapDecode(OsobniCislo.apply andThen DecodeResult.Value.apply)( + _.toString + ) + given Schema[WhoWhen] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/parameters/shared/codecs/src/Codecs.scala b/domain/parameters/shared/codecs/src/Codecs.scala new file mode 100644 index 0000000..751e79b --- /dev/null +++ b/domain/parameters/shared/codecs/src/Codecs.scala @@ -0,0 +1,10 @@ +package mdr.pdb +package parameters +package codecs + +import zio.json.* + +trait Codecs: + + given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen + given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/parameters/shared/json/src/Codecs.scala b/domain/parameters/shared/json/src/Codecs.scala deleted file mode 100644 index b849072..0000000 --- a/domain/parameters/shared/json/src/Codecs.scala +++ /dev/null @@ -1,10 +0,0 @@ -package mdr.pdb -package parameters -package json - -import zio.json.* - -trait Codecs: - - given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen - given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/proof/command/api/src/ProofCommandApi.scala b/domain/proof/command/api/src/ProofCommandApi.scala new file mode 100644 index 0000000..12f747e --- /dev/null +++ b/domain/proof/command/api/src/ProofCommandApi.scala @@ -0,0 +1,16 @@ +package mdr.pdb.proof.command +package api + +import endpoints.Endpoints +import entity.ProofCommandBus +import fiftyforms.tapir.CustomTapir +import fiftyforms.tapir.InternalServerError + +object ProofCommandApi extends CustomTapir: + + val submitCommand: ZServerEndpoint[ProofCommandBus, Any] = + Endpoints.submitCommand.zServerLogic(cmd => + ProofCommandBus + .submitCommand(cmd) + .mapError(InternalServerError.fromThrowable) + ) diff --git a/domain/proof/command/codecs/src/Codecs.scala b/domain/proof/command/codecs/src/Codecs.scala new file mode 100644 index 0000000..0e75f82 --- /dev/null +++ b/domain/proof/command/codecs/src/Codecs.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof.command +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends proof.codecs.JsonCodecs: + + given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen + given JsonCodec[CreateProof] = DeriveJsonCodec.gen + given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen + given JsonCodec[UpdateProof] = DeriveJsonCodec.gen + given JsonCodec[RevokeProof] = DeriveJsonCodec.gen + given JsonCodec[Command] = DeriveJsonCodec.gen + +trait TapirCodecs extends proof.codecs.TapirCodecs: + + given Schema[AuthorizeOption] = Schema.derived + given Schema[CreateProof] = Schema.derived + given Schema[AuthorizeProof] = Schema.derived + given Schema[UpdateProof] = Schema.derived + given Schema[RevokeProof] = Schema.derived + given Schema[Command] = Schema.derived diff --git a/domain/proof/command/endpoints/src/Endpoints.scala b/domain/proof/command/endpoints/src/Endpoints.scala new file mode 100644 index 0000000..dcd114e --- /dev/null +++ b/domain/proof/command/endpoints/src/Endpoints.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof +package command +package endpoints + +import fiftyforms.tapir.CustomTapir +import mdr.pdb.proof.command.codecs.Codecs +import fiftyforms.tapir.ServerError +import zio.json.JsonCodec +import zio.json.DeriveJsonCodec +import sttp.model.StatusCode + +object Endpoints + extends mdr.pdb.endpoints.Endpoints + with CustomTapir + with Codecs: + + val submitCommand: Endpoint[Unit, Command, ServerError, Unit, Any] = + endpoint + .in("command") + .in("proof") + .post + .in(jsonBody[Command]) + .out(statusCode(StatusCode.Accepted)) + .errorOut(jsonBody[ServerError]) diff --git a/build.sbt b/build.sbt index e6f5e40..227e292 100644 --- a/build.sbt +++ b/build.sbt @@ -14,38 +14,38 @@ lazy val proof = entityProject("proof", file("domain/proof")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .repo(_.dependsOn(`mongo-support`)) .entity(_.dependsOn(`akka-persistence-support`)) .projection(_.dependsOn(`akka-persistence-support`)) - .endpoints(_.dependsOn(`tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val parameters = entityProject("parameters", file("domain/parameters")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) - .endpoints(_.dependsOn(`tapir-support`)) + .codecs(_.dependsOn(codecs, `tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val users = entityProject("users", file("domain/users")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val core = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("core")) -lazy val json = crossProject(JSPlatform, JVMPlatform) +lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) - .in(file("json")) + .in(file("codecs")) .settings(IWDeps.zioJson) - .dependsOn(core) + .dependsOn(core, `tapir-support`) lazy val endpoints = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("endpoints")) - .dependsOn(core, json, `tapir-support`) + .dependsOn(core, codecs, `tapir-support`) // TODO: move all from fiftyforms to iterative works lazy val ui = (project in file("fiftyforms/ui")) @@ -80,7 +80,11 @@ lazy val `akka-persistence-support` = project .in(file("fiftyforms/akka-persistence")) - .settings(IWDeps.akka.profiles.eventsourcedJdbcProjection) + .settings( + IWDeps.akka.libs.persistence, + libraryDependencies += "com.typesafe.akka" %% "akka-cluster-sharding-typed" % IWDeps.akka.V, + IWDeps.akka.profiles.eventsourcedJdbcProjection + ) lazy val app = (project in file("app")) .enablePlugins(ScalaJSPlugin, VitePlugin) @@ -166,6 +170,7 @@ proof.query.api, proof.command.api, proof.query.projection, + proof.command.entity, endpoints.jvm ) diff --git a/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala new file mode 100644 index 0000000..22ebfb8 --- /dev/null +++ b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala @@ -0,0 +1,26 @@ +package mdr.pdb +package codecs + +import zio.json.* +import fiftyforms.tapir.CustomTapir + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + given JsonCodec[WhoWhen] = DeriveJsonCodec.gen + given JsonCodec[OsobniCislo] = + JsonCodec.string.transform(OsobniCislo.apply, _.toString) + given JsonFieldEncoder[OsobniCislo] = + JsonFieldEncoder.string.contramap(_.toString) + given JsonFieldDecoder[OsobniCislo] = + JsonFieldDecoder.string.map(OsobniCislo(_)) + +trait TapirCodecs extends CustomTapir: + given Schema[OsobniCislo] = Schema.string + given Codec.PlainCodec[OsobniCislo] = + Codec.string.mapDecode(OsobniCislo.apply andThen DecodeResult.Value.apply)( + _.toString + ) + given Schema[WhoWhen] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/parameters/shared/codecs/src/Codecs.scala b/domain/parameters/shared/codecs/src/Codecs.scala new file mode 100644 index 0000000..751e79b --- /dev/null +++ b/domain/parameters/shared/codecs/src/Codecs.scala @@ -0,0 +1,10 @@ +package mdr.pdb +package parameters +package codecs + +import zio.json.* + +trait Codecs: + + given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen + given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/parameters/shared/json/src/Codecs.scala b/domain/parameters/shared/json/src/Codecs.scala deleted file mode 100644 index b849072..0000000 --- a/domain/parameters/shared/json/src/Codecs.scala +++ /dev/null @@ -1,10 +0,0 @@ -package mdr.pdb -package parameters -package json - -import zio.json.* - -trait Codecs: - - given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen - given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/proof/command/api/src/ProofCommandApi.scala b/domain/proof/command/api/src/ProofCommandApi.scala new file mode 100644 index 0000000..12f747e --- /dev/null +++ b/domain/proof/command/api/src/ProofCommandApi.scala @@ -0,0 +1,16 @@ +package mdr.pdb.proof.command +package api + +import endpoints.Endpoints +import entity.ProofCommandBus +import fiftyforms.tapir.CustomTapir +import fiftyforms.tapir.InternalServerError + +object ProofCommandApi extends CustomTapir: + + val submitCommand: ZServerEndpoint[ProofCommandBus, Any] = + Endpoints.submitCommand.zServerLogic(cmd => + ProofCommandBus + .submitCommand(cmd) + .mapError(InternalServerError.fromThrowable) + ) diff --git a/domain/proof/command/codecs/src/Codecs.scala b/domain/proof/command/codecs/src/Codecs.scala new file mode 100644 index 0000000..0e75f82 --- /dev/null +++ b/domain/proof/command/codecs/src/Codecs.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof.command +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends proof.codecs.JsonCodecs: + + given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen + given JsonCodec[CreateProof] = DeriveJsonCodec.gen + given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen + given JsonCodec[UpdateProof] = DeriveJsonCodec.gen + given JsonCodec[RevokeProof] = DeriveJsonCodec.gen + given JsonCodec[Command] = DeriveJsonCodec.gen + +trait TapirCodecs extends proof.codecs.TapirCodecs: + + given Schema[AuthorizeOption] = Schema.derived + given Schema[CreateProof] = Schema.derived + given Schema[AuthorizeProof] = Schema.derived + given Schema[UpdateProof] = Schema.derived + given Schema[RevokeProof] = Schema.derived + given Schema[Command] = Schema.derived diff --git a/domain/proof/command/endpoints/src/Endpoints.scala b/domain/proof/command/endpoints/src/Endpoints.scala new file mode 100644 index 0000000..dcd114e --- /dev/null +++ b/domain/proof/command/endpoints/src/Endpoints.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof +package command +package endpoints + +import fiftyforms.tapir.CustomTapir +import mdr.pdb.proof.command.codecs.Codecs +import fiftyforms.tapir.ServerError +import zio.json.JsonCodec +import zio.json.DeriveJsonCodec +import sttp.model.StatusCode + +object Endpoints + extends mdr.pdb.endpoints.Endpoints + with CustomTapir + with Codecs: + + val submitCommand: Endpoint[Unit, Command, ServerError, Unit, Any] = + endpoint + .in("command") + .in("proof") + .post + .in(jsonBody[Command]) + .out(statusCode(StatusCode.Accepted)) + .errorOut(jsonBody[ServerError]) diff --git a/domain/proof/command/entity/src/ProofBehavior.scala b/domain/proof/command/entity/src/ProofBehavior.scala new file mode 100644 index 0000000..f679981 --- /dev/null +++ b/domain/proof/command/entity/src/ProofBehavior.scala @@ -0,0 +1,120 @@ +package mdr.pdb +package proof +package command +package entity + +import akka.persistence.typed.scaladsl.Effect +import akka.persistence.typed.scaladsl.EventSourcedBehavior +import akka.persistence.typed.PersistenceId +import akka.actor.typed.Behavior +import akka.actor.typed.ActorRef +import akka.pattern.StatusReply +import akka.Done + +import fiftyforms.akka.* +import akka.actor.typed.ActorSystem +import akka.cluster.sharding.typed.scaladsl.EntityTypeKey +import akka.cluster.sharding.typed.scaladsl.EntityContext +import akka.cluster.sharding.typed.scaladsl.ClusterSharding +import akka.cluster.sharding.typed.scaladsl.Entity + +object ProofBehavior: + + type ReplyTo = ActorRef[StatusReply[Done]] + + case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) + + type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] + + type ProofReplyEffect = + akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] + + val EntityKey: EntityTypeKey[ProofCommand] = EntityTypeKey("Proof") + + def init(system: ActorSystem[_]): Unit = + val behaviorFactory: EntityContext[ProofCommand] => Behavior[ProofCommand] = + entityContext => ProofBehavior(entityContext.entityId) + ClusterSharding(system).init(Entity(EntityKey)(behaviorFactory)) + + def apply(persistenceId: String): Behavior[ProofCommand] = + import ProofEventHandler.* + EventSourcedBehavior + .withEnforcedReplies[ProofCommand, ProofEvent, State]( + persistenceId = PersistenceId.ofUniqueId(persistenceId), + emptyState = None, + commandHandler = handleProofCommand, + eventHandler = (state, event) => + state.handleEvent(event).orElse(unhandledEvent(event, state)) + ) + .withTagger(_ => Set(ProofEvent.Tag)) + + def handleProofCommand( + state: State, + command: ProofCommand + ): ProofReplyEffect = + handleCommand(state, command.command) match + case Some(events) => persist(events, command.meta, command.replyTo) + case _ => unhandled(command.command, command.replyTo) + + def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = + state match + case None => + cmd match + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + Authorized(note) + ) => + Some( + Seq( + ProofCreated(id, person, parameterId, criterionId, documents), + ProofAuthorized(id, note) + ) + ) + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + _ + ) => + Some( + Seq(ProofCreated(id, person, parameterId, criterionId, documents)) + ) + case _ => None + case Some(proof) if proof.isRevoked => None + case Some(proof) if proof.isAuthorized => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + case Some(proof) => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case UpdateProof(id, documents) => + Some(Seq(ProofUpdated(id, documents))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + + private def persist( + events: Seq[Event], + meta: WW, + replyTo: ReplyTo + ): ProofReplyEffect = + Effect + .persist(events.map(ProofEvent(_, meta))) + .thenReply(replyTo)(_ => StatusReply.Ack) + + private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = + Effect.unhandled.thenReply(replyTo)(s => + StatusReply.error(CommandNotAvailable(command, s)) + ) + + private def unhandledEvent(event: ProofEvent, state: State): Nothing = + throw UnhandledEvent(event, state) diff --git a/build.sbt b/build.sbt index e6f5e40..227e292 100644 --- a/build.sbt +++ b/build.sbt @@ -14,38 +14,38 @@ lazy val proof = entityProject("proof", file("domain/proof")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .repo(_.dependsOn(`mongo-support`)) .entity(_.dependsOn(`akka-persistence-support`)) .projection(_.dependsOn(`akka-persistence-support`)) - .endpoints(_.dependsOn(`tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val parameters = entityProject("parameters", file("domain/parameters")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) - .endpoints(_.dependsOn(`tapir-support`)) + .codecs(_.dependsOn(codecs, `tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val users = entityProject("users", file("domain/users")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val core = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("core")) -lazy val json = crossProject(JSPlatform, JVMPlatform) +lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) - .in(file("json")) + .in(file("codecs")) .settings(IWDeps.zioJson) - .dependsOn(core) + .dependsOn(core, `tapir-support`) lazy val endpoints = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("endpoints")) - .dependsOn(core, json, `tapir-support`) + .dependsOn(core, codecs, `tapir-support`) // TODO: move all from fiftyforms to iterative works lazy val ui = (project in file("fiftyforms/ui")) @@ -80,7 +80,11 @@ lazy val `akka-persistence-support` = project .in(file("fiftyforms/akka-persistence")) - .settings(IWDeps.akka.profiles.eventsourcedJdbcProjection) + .settings( + IWDeps.akka.libs.persistence, + libraryDependencies += "com.typesafe.akka" %% "akka-cluster-sharding-typed" % IWDeps.akka.V, + IWDeps.akka.profiles.eventsourcedJdbcProjection + ) lazy val app = (project in file("app")) .enablePlugins(ScalaJSPlugin, VitePlugin) @@ -166,6 +170,7 @@ proof.query.api, proof.command.api, proof.query.projection, + proof.command.entity, endpoints.jvm ) diff --git a/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala new file mode 100644 index 0000000..22ebfb8 --- /dev/null +++ b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala @@ -0,0 +1,26 @@ +package mdr.pdb +package codecs + +import zio.json.* +import fiftyforms.tapir.CustomTapir + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + given JsonCodec[WhoWhen] = DeriveJsonCodec.gen + given JsonCodec[OsobniCislo] = + JsonCodec.string.transform(OsobniCislo.apply, _.toString) + given JsonFieldEncoder[OsobniCislo] = + JsonFieldEncoder.string.contramap(_.toString) + given JsonFieldDecoder[OsobniCislo] = + JsonFieldDecoder.string.map(OsobniCislo(_)) + +trait TapirCodecs extends CustomTapir: + given Schema[OsobniCislo] = Schema.string + given Codec.PlainCodec[OsobniCislo] = + Codec.string.mapDecode(OsobniCislo.apply andThen DecodeResult.Value.apply)( + _.toString + ) + given Schema[WhoWhen] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/parameters/shared/codecs/src/Codecs.scala b/domain/parameters/shared/codecs/src/Codecs.scala new file mode 100644 index 0000000..751e79b --- /dev/null +++ b/domain/parameters/shared/codecs/src/Codecs.scala @@ -0,0 +1,10 @@ +package mdr.pdb +package parameters +package codecs + +import zio.json.* + +trait Codecs: + + given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen + given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/parameters/shared/json/src/Codecs.scala b/domain/parameters/shared/json/src/Codecs.scala deleted file mode 100644 index b849072..0000000 --- a/domain/parameters/shared/json/src/Codecs.scala +++ /dev/null @@ -1,10 +0,0 @@ -package mdr.pdb -package parameters -package json - -import zio.json.* - -trait Codecs: - - given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen - given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/proof/command/api/src/ProofCommandApi.scala b/domain/proof/command/api/src/ProofCommandApi.scala new file mode 100644 index 0000000..12f747e --- /dev/null +++ b/domain/proof/command/api/src/ProofCommandApi.scala @@ -0,0 +1,16 @@ +package mdr.pdb.proof.command +package api + +import endpoints.Endpoints +import entity.ProofCommandBus +import fiftyforms.tapir.CustomTapir +import fiftyforms.tapir.InternalServerError + +object ProofCommandApi extends CustomTapir: + + val submitCommand: ZServerEndpoint[ProofCommandBus, Any] = + Endpoints.submitCommand.zServerLogic(cmd => + ProofCommandBus + .submitCommand(cmd) + .mapError(InternalServerError.fromThrowable) + ) diff --git a/domain/proof/command/codecs/src/Codecs.scala b/domain/proof/command/codecs/src/Codecs.scala new file mode 100644 index 0000000..0e75f82 --- /dev/null +++ b/domain/proof/command/codecs/src/Codecs.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof.command +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends proof.codecs.JsonCodecs: + + given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen + given JsonCodec[CreateProof] = DeriveJsonCodec.gen + given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen + given JsonCodec[UpdateProof] = DeriveJsonCodec.gen + given JsonCodec[RevokeProof] = DeriveJsonCodec.gen + given JsonCodec[Command] = DeriveJsonCodec.gen + +trait TapirCodecs extends proof.codecs.TapirCodecs: + + given Schema[AuthorizeOption] = Schema.derived + given Schema[CreateProof] = Schema.derived + given Schema[AuthorizeProof] = Schema.derived + given Schema[UpdateProof] = Schema.derived + given Schema[RevokeProof] = Schema.derived + given Schema[Command] = Schema.derived diff --git a/domain/proof/command/endpoints/src/Endpoints.scala b/domain/proof/command/endpoints/src/Endpoints.scala new file mode 100644 index 0000000..dcd114e --- /dev/null +++ b/domain/proof/command/endpoints/src/Endpoints.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof +package command +package endpoints + +import fiftyforms.tapir.CustomTapir +import mdr.pdb.proof.command.codecs.Codecs +import fiftyforms.tapir.ServerError +import zio.json.JsonCodec +import zio.json.DeriveJsonCodec +import sttp.model.StatusCode + +object Endpoints + extends mdr.pdb.endpoints.Endpoints + with CustomTapir + with Codecs: + + val submitCommand: Endpoint[Unit, Command, ServerError, Unit, Any] = + endpoint + .in("command") + .in("proof") + .post + .in(jsonBody[Command]) + .out(statusCode(StatusCode.Accepted)) + .errorOut(jsonBody[ServerError]) diff --git a/domain/proof/command/entity/src/ProofBehavior.scala b/domain/proof/command/entity/src/ProofBehavior.scala new file mode 100644 index 0000000..f679981 --- /dev/null +++ b/domain/proof/command/entity/src/ProofBehavior.scala @@ -0,0 +1,120 @@ +package mdr.pdb +package proof +package command +package entity + +import akka.persistence.typed.scaladsl.Effect +import akka.persistence.typed.scaladsl.EventSourcedBehavior +import akka.persistence.typed.PersistenceId +import akka.actor.typed.Behavior +import akka.actor.typed.ActorRef +import akka.pattern.StatusReply +import akka.Done + +import fiftyforms.akka.* +import akka.actor.typed.ActorSystem +import akka.cluster.sharding.typed.scaladsl.EntityTypeKey +import akka.cluster.sharding.typed.scaladsl.EntityContext +import akka.cluster.sharding.typed.scaladsl.ClusterSharding +import akka.cluster.sharding.typed.scaladsl.Entity + +object ProofBehavior: + + type ReplyTo = ActorRef[StatusReply[Done]] + + case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) + + type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] + + type ProofReplyEffect = + akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] + + val EntityKey: EntityTypeKey[ProofCommand] = EntityTypeKey("Proof") + + def init(system: ActorSystem[_]): Unit = + val behaviorFactory: EntityContext[ProofCommand] => Behavior[ProofCommand] = + entityContext => ProofBehavior(entityContext.entityId) + ClusterSharding(system).init(Entity(EntityKey)(behaviorFactory)) + + def apply(persistenceId: String): Behavior[ProofCommand] = + import ProofEventHandler.* + EventSourcedBehavior + .withEnforcedReplies[ProofCommand, ProofEvent, State]( + persistenceId = PersistenceId.ofUniqueId(persistenceId), + emptyState = None, + commandHandler = handleProofCommand, + eventHandler = (state, event) => + state.handleEvent(event).orElse(unhandledEvent(event, state)) + ) + .withTagger(_ => Set(ProofEvent.Tag)) + + def handleProofCommand( + state: State, + command: ProofCommand + ): ProofReplyEffect = + handleCommand(state, command.command) match + case Some(events) => persist(events, command.meta, command.replyTo) + case _ => unhandled(command.command, command.replyTo) + + def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = + state match + case None => + cmd match + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + Authorized(note) + ) => + Some( + Seq( + ProofCreated(id, person, parameterId, criterionId, documents), + ProofAuthorized(id, note) + ) + ) + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + _ + ) => + Some( + Seq(ProofCreated(id, person, parameterId, criterionId, documents)) + ) + case _ => None + case Some(proof) if proof.isRevoked => None + case Some(proof) if proof.isAuthorized => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + case Some(proof) => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case UpdateProof(id, documents) => + Some(Seq(ProofUpdated(id, documents))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + + private def persist( + events: Seq[Event], + meta: WW, + replyTo: ReplyTo + ): ProofReplyEffect = + Effect + .persist(events.map(ProofEvent(_, meta))) + .thenReply(replyTo)(_ => StatusReply.Ack) + + private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = + Effect.unhandled.thenReply(replyTo)(s => + StatusReply.error(CommandNotAvailable(command, s)) + ) + + private def unhandledEvent(event: ProofEvent, state: State): Nothing = + throw UnhandledEvent(event, state) diff --git a/domain/proof/command/entity/src/ProofBehaviour.scala b/domain/proof/command/entity/src/ProofBehaviour.scala deleted file mode 100644 index e8c7e4e..0000000 --- a/domain/proof/command/entity/src/ProofBehaviour.scala +++ /dev/null @@ -1,108 +0,0 @@ -package mdr.pdb -package proof -package command -package entity - -import akka.persistence.typed.scaladsl.EventSourcedBehavior -import akka.persistence.typed.PersistenceId -import akka.actor.typed.Behavior -import akka.actor.typed.ActorRef -import akka.pattern.StatusReply -import akka.Done - -import fiftyforms.akka.* -import akka.persistence.typed.scaladsl.Effect - -object ProofBehaviour: - - type ReplyTo = ActorRef[StatusReply[Done]] - - case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) - - type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] - - type ProofReplyEffect = - akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] - - def apply(persistenceId: PersistenceId): Behavior[ProofCommand] = - import ProofEventHandler.* - EventSourcedBehavior - .withEnforcedReplies[ProofCommand, ProofEvent, State]( - persistenceId = persistenceId, - emptyState = None, - commandHandler = handleProofCommand, - eventHandler = (state, event) => - state.handleEvent(event).orElse(unhandledEvent(event, state)) - ) - .withTagger(_ => Set(ProofEvent.Tag)) - - def handleProofCommand( - state: State, - command: ProofCommand - ): ProofReplyEffect = - handleCommand(state, command.command) match - case Some(events) => persist(events, command.meta, command.replyTo) - case _ => unhandled(command.command, command.replyTo) - - def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = - state match - case None => - cmd match - case CreateProof( - id, - person, - parameterId, - criterionId, - documents, - Authorized(note) - ) => - Some( - Seq( - ProofCreated(id, person, parameterId, criterionId, documents), - ProofAuthorized(id, note) - ) - ) - case CreateProof( - id, - person, - parameterId, - criterionId, - documents, - _ - ) => - Some( - Seq(ProofCreated(id, person, parameterId, criterionId, documents)) - ) - case _ => None - case Some(proof) if proof.isRevoked => None - case Some(proof) if proof.isAuthorized => - cmd match - case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) - case RevokeProof(id, reason, since, documents) => - Some(Seq((ProofRevoked(id, reason, since, documents)))) - case _ => None - case Some(proof) => - cmd match - case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) - case UpdateProof(id, documents) => - Some(Seq(ProofUpdated(id, documents))) - case RevokeProof(id, reason, since, documents) => - Some(Seq((ProofRevoked(id, reason, since, documents)))) - case _ => None - - private def persist( - events: Seq[Event], - meta: WW, - replyTo: ReplyTo - ): ProofReplyEffect = - Effect - .persist(events.map(ProofEvent(_, meta))) - .thenReply(replyTo)(_ => StatusReply.Ack) - - private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = - Effect.unhandled.thenReply(replyTo)(s => - StatusReply.error(CommandNotAvailable(command, s)) - ) - - private def unhandledEvent(event: ProofEvent, state: State): Nothing = - throw UnhandledEvent(event, state) diff --git a/build.sbt b/build.sbt index e6f5e40..227e292 100644 --- a/build.sbt +++ b/build.sbt @@ -14,38 +14,38 @@ lazy val proof = entityProject("proof", file("domain/proof")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .repo(_.dependsOn(`mongo-support`)) .entity(_.dependsOn(`akka-persistence-support`)) .projection(_.dependsOn(`akka-persistence-support`)) - .endpoints(_.dependsOn(`tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val parameters = entityProject("parameters", file("domain/parameters")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) - .endpoints(_.dependsOn(`tapir-support`)) + .codecs(_.dependsOn(codecs, `tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val users = entityProject("users", file("domain/users")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val core = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("core")) -lazy val json = crossProject(JSPlatform, JVMPlatform) +lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) - .in(file("json")) + .in(file("codecs")) .settings(IWDeps.zioJson) - .dependsOn(core) + .dependsOn(core, `tapir-support`) lazy val endpoints = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("endpoints")) - .dependsOn(core, json, `tapir-support`) + .dependsOn(core, codecs, `tapir-support`) // TODO: move all from fiftyforms to iterative works lazy val ui = (project in file("fiftyforms/ui")) @@ -80,7 +80,11 @@ lazy val `akka-persistence-support` = project .in(file("fiftyforms/akka-persistence")) - .settings(IWDeps.akka.profiles.eventsourcedJdbcProjection) + .settings( + IWDeps.akka.libs.persistence, + libraryDependencies += "com.typesafe.akka" %% "akka-cluster-sharding-typed" % IWDeps.akka.V, + IWDeps.akka.profiles.eventsourcedJdbcProjection + ) lazy val app = (project in file("app")) .enablePlugins(ScalaJSPlugin, VitePlugin) @@ -166,6 +170,7 @@ proof.query.api, proof.command.api, proof.query.projection, + proof.command.entity, endpoints.jvm ) diff --git a/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala new file mode 100644 index 0000000..22ebfb8 --- /dev/null +++ b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala @@ -0,0 +1,26 @@ +package mdr.pdb +package codecs + +import zio.json.* +import fiftyforms.tapir.CustomTapir + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + given JsonCodec[WhoWhen] = DeriveJsonCodec.gen + given JsonCodec[OsobniCislo] = + JsonCodec.string.transform(OsobniCislo.apply, _.toString) + given JsonFieldEncoder[OsobniCislo] = + JsonFieldEncoder.string.contramap(_.toString) + given JsonFieldDecoder[OsobniCislo] = + JsonFieldDecoder.string.map(OsobniCislo(_)) + +trait TapirCodecs extends CustomTapir: + given Schema[OsobniCislo] = Schema.string + given Codec.PlainCodec[OsobniCislo] = + Codec.string.mapDecode(OsobniCislo.apply andThen DecodeResult.Value.apply)( + _.toString + ) + given Schema[WhoWhen] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/parameters/shared/codecs/src/Codecs.scala b/domain/parameters/shared/codecs/src/Codecs.scala new file mode 100644 index 0000000..751e79b --- /dev/null +++ b/domain/parameters/shared/codecs/src/Codecs.scala @@ -0,0 +1,10 @@ +package mdr.pdb +package parameters +package codecs + +import zio.json.* + +trait Codecs: + + given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen + given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/parameters/shared/json/src/Codecs.scala b/domain/parameters/shared/json/src/Codecs.scala deleted file mode 100644 index b849072..0000000 --- a/domain/parameters/shared/json/src/Codecs.scala +++ /dev/null @@ -1,10 +0,0 @@ -package mdr.pdb -package parameters -package json - -import zio.json.* - -trait Codecs: - - given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen - given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/proof/command/api/src/ProofCommandApi.scala b/domain/proof/command/api/src/ProofCommandApi.scala new file mode 100644 index 0000000..12f747e --- /dev/null +++ b/domain/proof/command/api/src/ProofCommandApi.scala @@ -0,0 +1,16 @@ +package mdr.pdb.proof.command +package api + +import endpoints.Endpoints +import entity.ProofCommandBus +import fiftyforms.tapir.CustomTapir +import fiftyforms.tapir.InternalServerError + +object ProofCommandApi extends CustomTapir: + + val submitCommand: ZServerEndpoint[ProofCommandBus, Any] = + Endpoints.submitCommand.zServerLogic(cmd => + ProofCommandBus + .submitCommand(cmd) + .mapError(InternalServerError.fromThrowable) + ) diff --git a/domain/proof/command/codecs/src/Codecs.scala b/domain/proof/command/codecs/src/Codecs.scala new file mode 100644 index 0000000..0e75f82 --- /dev/null +++ b/domain/proof/command/codecs/src/Codecs.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof.command +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends proof.codecs.JsonCodecs: + + given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen + given JsonCodec[CreateProof] = DeriveJsonCodec.gen + given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen + given JsonCodec[UpdateProof] = DeriveJsonCodec.gen + given JsonCodec[RevokeProof] = DeriveJsonCodec.gen + given JsonCodec[Command] = DeriveJsonCodec.gen + +trait TapirCodecs extends proof.codecs.TapirCodecs: + + given Schema[AuthorizeOption] = Schema.derived + given Schema[CreateProof] = Schema.derived + given Schema[AuthorizeProof] = Schema.derived + given Schema[UpdateProof] = Schema.derived + given Schema[RevokeProof] = Schema.derived + given Schema[Command] = Schema.derived diff --git a/domain/proof/command/endpoints/src/Endpoints.scala b/domain/proof/command/endpoints/src/Endpoints.scala new file mode 100644 index 0000000..dcd114e --- /dev/null +++ b/domain/proof/command/endpoints/src/Endpoints.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof +package command +package endpoints + +import fiftyforms.tapir.CustomTapir +import mdr.pdb.proof.command.codecs.Codecs +import fiftyforms.tapir.ServerError +import zio.json.JsonCodec +import zio.json.DeriveJsonCodec +import sttp.model.StatusCode + +object Endpoints + extends mdr.pdb.endpoints.Endpoints + with CustomTapir + with Codecs: + + val submitCommand: Endpoint[Unit, Command, ServerError, Unit, Any] = + endpoint + .in("command") + .in("proof") + .post + .in(jsonBody[Command]) + .out(statusCode(StatusCode.Accepted)) + .errorOut(jsonBody[ServerError]) diff --git a/domain/proof/command/entity/src/ProofBehavior.scala b/domain/proof/command/entity/src/ProofBehavior.scala new file mode 100644 index 0000000..f679981 --- /dev/null +++ b/domain/proof/command/entity/src/ProofBehavior.scala @@ -0,0 +1,120 @@ +package mdr.pdb +package proof +package command +package entity + +import akka.persistence.typed.scaladsl.Effect +import akka.persistence.typed.scaladsl.EventSourcedBehavior +import akka.persistence.typed.PersistenceId +import akka.actor.typed.Behavior +import akka.actor.typed.ActorRef +import akka.pattern.StatusReply +import akka.Done + +import fiftyforms.akka.* +import akka.actor.typed.ActorSystem +import akka.cluster.sharding.typed.scaladsl.EntityTypeKey +import akka.cluster.sharding.typed.scaladsl.EntityContext +import akka.cluster.sharding.typed.scaladsl.ClusterSharding +import akka.cluster.sharding.typed.scaladsl.Entity + +object ProofBehavior: + + type ReplyTo = ActorRef[StatusReply[Done]] + + case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) + + type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] + + type ProofReplyEffect = + akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] + + val EntityKey: EntityTypeKey[ProofCommand] = EntityTypeKey("Proof") + + def init(system: ActorSystem[_]): Unit = + val behaviorFactory: EntityContext[ProofCommand] => Behavior[ProofCommand] = + entityContext => ProofBehavior(entityContext.entityId) + ClusterSharding(system).init(Entity(EntityKey)(behaviorFactory)) + + def apply(persistenceId: String): Behavior[ProofCommand] = + import ProofEventHandler.* + EventSourcedBehavior + .withEnforcedReplies[ProofCommand, ProofEvent, State]( + persistenceId = PersistenceId.ofUniqueId(persistenceId), + emptyState = None, + commandHandler = handleProofCommand, + eventHandler = (state, event) => + state.handleEvent(event).orElse(unhandledEvent(event, state)) + ) + .withTagger(_ => Set(ProofEvent.Tag)) + + def handleProofCommand( + state: State, + command: ProofCommand + ): ProofReplyEffect = + handleCommand(state, command.command) match + case Some(events) => persist(events, command.meta, command.replyTo) + case _ => unhandled(command.command, command.replyTo) + + def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = + state match + case None => + cmd match + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + Authorized(note) + ) => + Some( + Seq( + ProofCreated(id, person, parameterId, criterionId, documents), + ProofAuthorized(id, note) + ) + ) + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + _ + ) => + Some( + Seq(ProofCreated(id, person, parameterId, criterionId, documents)) + ) + case _ => None + case Some(proof) if proof.isRevoked => None + case Some(proof) if proof.isAuthorized => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + case Some(proof) => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case UpdateProof(id, documents) => + Some(Seq(ProofUpdated(id, documents))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + + private def persist( + events: Seq[Event], + meta: WW, + replyTo: ReplyTo + ): ProofReplyEffect = + Effect + .persist(events.map(ProofEvent(_, meta))) + .thenReply(replyTo)(_ => StatusReply.Ack) + + private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = + Effect.unhandled.thenReply(replyTo)(s => + StatusReply.error(CommandNotAvailable(command, s)) + ) + + private def unhandledEvent(event: ProofEvent, state: State): Nothing = + throw UnhandledEvent(event, state) diff --git a/domain/proof/command/entity/src/ProofBehaviour.scala b/domain/proof/command/entity/src/ProofBehaviour.scala deleted file mode 100644 index e8c7e4e..0000000 --- a/domain/proof/command/entity/src/ProofBehaviour.scala +++ /dev/null @@ -1,108 +0,0 @@ -package mdr.pdb -package proof -package command -package entity - -import akka.persistence.typed.scaladsl.EventSourcedBehavior -import akka.persistence.typed.PersistenceId -import akka.actor.typed.Behavior -import akka.actor.typed.ActorRef -import akka.pattern.StatusReply -import akka.Done - -import fiftyforms.akka.* -import akka.persistence.typed.scaladsl.Effect - -object ProofBehaviour: - - type ReplyTo = ActorRef[StatusReply[Done]] - - case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) - - type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] - - type ProofReplyEffect = - akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] - - def apply(persistenceId: PersistenceId): Behavior[ProofCommand] = - import ProofEventHandler.* - EventSourcedBehavior - .withEnforcedReplies[ProofCommand, ProofEvent, State]( - persistenceId = persistenceId, - emptyState = None, - commandHandler = handleProofCommand, - eventHandler = (state, event) => - state.handleEvent(event).orElse(unhandledEvent(event, state)) - ) - .withTagger(_ => Set(ProofEvent.Tag)) - - def handleProofCommand( - state: State, - command: ProofCommand - ): ProofReplyEffect = - handleCommand(state, command.command) match - case Some(events) => persist(events, command.meta, command.replyTo) - case _ => unhandled(command.command, command.replyTo) - - def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = - state match - case None => - cmd match - case CreateProof( - id, - person, - parameterId, - criterionId, - documents, - Authorized(note) - ) => - Some( - Seq( - ProofCreated(id, person, parameterId, criterionId, documents), - ProofAuthorized(id, note) - ) - ) - case CreateProof( - id, - person, - parameterId, - criterionId, - documents, - _ - ) => - Some( - Seq(ProofCreated(id, person, parameterId, criterionId, documents)) - ) - case _ => None - case Some(proof) if proof.isRevoked => None - case Some(proof) if proof.isAuthorized => - cmd match - case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) - case RevokeProof(id, reason, since, documents) => - Some(Seq((ProofRevoked(id, reason, since, documents)))) - case _ => None - case Some(proof) => - cmd match - case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) - case UpdateProof(id, documents) => - Some(Seq(ProofUpdated(id, documents))) - case RevokeProof(id, reason, since, documents) => - Some(Seq((ProofRevoked(id, reason, since, documents)))) - case _ => None - - private def persist( - events: Seq[Event], - meta: WW, - replyTo: ReplyTo - ): ProofReplyEffect = - Effect - .persist(events.map(ProofEvent(_, meta))) - .thenReply(replyTo)(_ => StatusReply.Ack) - - private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = - Effect.unhandled.thenReply(replyTo)(s => - StatusReply.error(CommandNotAvailable(command, s)) - ) - - private def unhandledEvent(event: ProofEvent, state: State): Nothing = - throw UnhandledEvent(event, state) diff --git a/domain/proof/command/entity/src/ProofCommandBus.scala b/domain/proof/command/entity/src/ProofCommandBus.scala new file mode 100644 index 0000000..3167e02 --- /dev/null +++ b/domain/proof/command/entity/src/ProofCommandBus.scala @@ -0,0 +1,50 @@ +package mdr.pdb +package proof +package command +package entity + +import zio.* +import akka.actor.typed.ActorSystem +import akka.cluster.sharding.typed.scaladsl.EntityContext +import akka.actor.typed.Behavior +import akka.cluster.sharding.typed.scaladsl.ClusterSharding +import akka.util.Timeout +import java.time.Instant + +object ProofCommandBus: + def submitCommand(command: Command): RIO[ProofCommandBus, Unit] = + ZIO.serviceWithZIO(_.submitCommand(command)) + + val layer: RLayer[ActorSystem[_], ProofCommandBus] = + ZIO + .serviceWithZIO[ActorSystem[_]](system => + for + timeout <- Task.attempt( + Timeout.create( + system.settings.config.getDuration("proof-bus.timeout") + ) + ) + // TODO: init only once + _ <- Task.attempt(ProofBehavior.init(system)) + yield ProofCommandBus(system)(using timeout) + ) + .toLayer + +class ProofCommandBus(system: ActorSystem[_])(using timeout: Timeout): + private val sharding = ClusterSharding(system) + + def submitCommand(command: Command): Task[Unit] = + for + entityRef <- Task.attempt( + sharding.entityRefFor(ProofBehavior.EntityKey, command.id) + ) + reply <- ZIO.fromFuture(_ => + entityRef.askWithStatus( + ProofBehavior.ProofCommand( + command, + WhoWhen(OsobniCislo("0123"), Instant.now()), + _ + ) + ) + ) + yield () diff --git a/build.sbt b/build.sbt index e6f5e40..227e292 100644 --- a/build.sbt +++ b/build.sbt @@ -14,38 +14,38 @@ lazy val proof = entityProject("proof", file("domain/proof")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .repo(_.dependsOn(`mongo-support`)) .entity(_.dependsOn(`akka-persistence-support`)) .projection(_.dependsOn(`akka-persistence-support`)) - .endpoints(_.dependsOn(`tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val parameters = entityProject("parameters", file("domain/parameters")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) - .endpoints(_.dependsOn(`tapir-support`)) + .codecs(_.dependsOn(codecs, `tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val users = entityProject("users", file("domain/users")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val core = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("core")) -lazy val json = crossProject(JSPlatform, JVMPlatform) +lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) - .in(file("json")) + .in(file("codecs")) .settings(IWDeps.zioJson) - .dependsOn(core) + .dependsOn(core, `tapir-support`) lazy val endpoints = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("endpoints")) - .dependsOn(core, json, `tapir-support`) + .dependsOn(core, codecs, `tapir-support`) // TODO: move all from fiftyforms to iterative works lazy val ui = (project in file("fiftyforms/ui")) @@ -80,7 +80,11 @@ lazy val `akka-persistence-support` = project .in(file("fiftyforms/akka-persistence")) - .settings(IWDeps.akka.profiles.eventsourcedJdbcProjection) + .settings( + IWDeps.akka.libs.persistence, + libraryDependencies += "com.typesafe.akka" %% "akka-cluster-sharding-typed" % IWDeps.akka.V, + IWDeps.akka.profiles.eventsourcedJdbcProjection + ) lazy val app = (project in file("app")) .enablePlugins(ScalaJSPlugin, VitePlugin) @@ -166,6 +170,7 @@ proof.query.api, proof.command.api, proof.query.projection, + proof.command.entity, endpoints.jvm ) diff --git a/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala new file mode 100644 index 0000000..22ebfb8 --- /dev/null +++ b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala @@ -0,0 +1,26 @@ +package mdr.pdb +package codecs + +import zio.json.* +import fiftyforms.tapir.CustomTapir + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + given JsonCodec[WhoWhen] = DeriveJsonCodec.gen + given JsonCodec[OsobniCislo] = + JsonCodec.string.transform(OsobniCislo.apply, _.toString) + given JsonFieldEncoder[OsobniCislo] = + JsonFieldEncoder.string.contramap(_.toString) + given JsonFieldDecoder[OsobniCislo] = + JsonFieldDecoder.string.map(OsobniCislo(_)) + +trait TapirCodecs extends CustomTapir: + given Schema[OsobniCislo] = Schema.string + given Codec.PlainCodec[OsobniCislo] = + Codec.string.mapDecode(OsobniCislo.apply andThen DecodeResult.Value.apply)( + _.toString + ) + given Schema[WhoWhen] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/parameters/shared/codecs/src/Codecs.scala b/domain/parameters/shared/codecs/src/Codecs.scala new file mode 100644 index 0000000..751e79b --- /dev/null +++ b/domain/parameters/shared/codecs/src/Codecs.scala @@ -0,0 +1,10 @@ +package mdr.pdb +package parameters +package codecs + +import zio.json.* + +trait Codecs: + + given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen + given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/parameters/shared/json/src/Codecs.scala b/domain/parameters/shared/json/src/Codecs.scala deleted file mode 100644 index b849072..0000000 --- a/domain/parameters/shared/json/src/Codecs.scala +++ /dev/null @@ -1,10 +0,0 @@ -package mdr.pdb -package parameters -package json - -import zio.json.* - -trait Codecs: - - given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen - given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/proof/command/api/src/ProofCommandApi.scala b/domain/proof/command/api/src/ProofCommandApi.scala new file mode 100644 index 0000000..12f747e --- /dev/null +++ b/domain/proof/command/api/src/ProofCommandApi.scala @@ -0,0 +1,16 @@ +package mdr.pdb.proof.command +package api + +import endpoints.Endpoints +import entity.ProofCommandBus +import fiftyforms.tapir.CustomTapir +import fiftyforms.tapir.InternalServerError + +object ProofCommandApi extends CustomTapir: + + val submitCommand: ZServerEndpoint[ProofCommandBus, Any] = + Endpoints.submitCommand.zServerLogic(cmd => + ProofCommandBus + .submitCommand(cmd) + .mapError(InternalServerError.fromThrowable) + ) diff --git a/domain/proof/command/codecs/src/Codecs.scala b/domain/proof/command/codecs/src/Codecs.scala new file mode 100644 index 0000000..0e75f82 --- /dev/null +++ b/domain/proof/command/codecs/src/Codecs.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof.command +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends proof.codecs.JsonCodecs: + + given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen + given JsonCodec[CreateProof] = DeriveJsonCodec.gen + given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen + given JsonCodec[UpdateProof] = DeriveJsonCodec.gen + given JsonCodec[RevokeProof] = DeriveJsonCodec.gen + given JsonCodec[Command] = DeriveJsonCodec.gen + +trait TapirCodecs extends proof.codecs.TapirCodecs: + + given Schema[AuthorizeOption] = Schema.derived + given Schema[CreateProof] = Schema.derived + given Schema[AuthorizeProof] = Schema.derived + given Schema[UpdateProof] = Schema.derived + given Schema[RevokeProof] = Schema.derived + given Schema[Command] = Schema.derived diff --git a/domain/proof/command/endpoints/src/Endpoints.scala b/domain/proof/command/endpoints/src/Endpoints.scala new file mode 100644 index 0000000..dcd114e --- /dev/null +++ b/domain/proof/command/endpoints/src/Endpoints.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof +package command +package endpoints + +import fiftyforms.tapir.CustomTapir +import mdr.pdb.proof.command.codecs.Codecs +import fiftyforms.tapir.ServerError +import zio.json.JsonCodec +import zio.json.DeriveJsonCodec +import sttp.model.StatusCode + +object Endpoints + extends mdr.pdb.endpoints.Endpoints + with CustomTapir + with Codecs: + + val submitCommand: Endpoint[Unit, Command, ServerError, Unit, Any] = + endpoint + .in("command") + .in("proof") + .post + .in(jsonBody[Command]) + .out(statusCode(StatusCode.Accepted)) + .errorOut(jsonBody[ServerError]) diff --git a/domain/proof/command/entity/src/ProofBehavior.scala b/domain/proof/command/entity/src/ProofBehavior.scala new file mode 100644 index 0000000..f679981 --- /dev/null +++ b/domain/proof/command/entity/src/ProofBehavior.scala @@ -0,0 +1,120 @@ +package mdr.pdb +package proof +package command +package entity + +import akka.persistence.typed.scaladsl.Effect +import akka.persistence.typed.scaladsl.EventSourcedBehavior +import akka.persistence.typed.PersistenceId +import akka.actor.typed.Behavior +import akka.actor.typed.ActorRef +import akka.pattern.StatusReply +import akka.Done + +import fiftyforms.akka.* +import akka.actor.typed.ActorSystem +import akka.cluster.sharding.typed.scaladsl.EntityTypeKey +import akka.cluster.sharding.typed.scaladsl.EntityContext +import akka.cluster.sharding.typed.scaladsl.ClusterSharding +import akka.cluster.sharding.typed.scaladsl.Entity + +object ProofBehavior: + + type ReplyTo = ActorRef[StatusReply[Done]] + + case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) + + type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] + + type ProofReplyEffect = + akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] + + val EntityKey: EntityTypeKey[ProofCommand] = EntityTypeKey("Proof") + + def init(system: ActorSystem[_]): Unit = + val behaviorFactory: EntityContext[ProofCommand] => Behavior[ProofCommand] = + entityContext => ProofBehavior(entityContext.entityId) + ClusterSharding(system).init(Entity(EntityKey)(behaviorFactory)) + + def apply(persistenceId: String): Behavior[ProofCommand] = + import ProofEventHandler.* + EventSourcedBehavior + .withEnforcedReplies[ProofCommand, ProofEvent, State]( + persistenceId = PersistenceId.ofUniqueId(persistenceId), + emptyState = None, + commandHandler = handleProofCommand, + eventHandler = (state, event) => + state.handleEvent(event).orElse(unhandledEvent(event, state)) + ) + .withTagger(_ => Set(ProofEvent.Tag)) + + def handleProofCommand( + state: State, + command: ProofCommand + ): ProofReplyEffect = + handleCommand(state, command.command) match + case Some(events) => persist(events, command.meta, command.replyTo) + case _ => unhandled(command.command, command.replyTo) + + def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = + state match + case None => + cmd match + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + Authorized(note) + ) => + Some( + Seq( + ProofCreated(id, person, parameterId, criterionId, documents), + ProofAuthorized(id, note) + ) + ) + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + _ + ) => + Some( + Seq(ProofCreated(id, person, parameterId, criterionId, documents)) + ) + case _ => None + case Some(proof) if proof.isRevoked => None + case Some(proof) if proof.isAuthorized => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + case Some(proof) => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case UpdateProof(id, documents) => + Some(Seq(ProofUpdated(id, documents))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + + private def persist( + events: Seq[Event], + meta: WW, + replyTo: ReplyTo + ): ProofReplyEffect = + Effect + .persist(events.map(ProofEvent(_, meta))) + .thenReply(replyTo)(_ => StatusReply.Ack) + + private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = + Effect.unhandled.thenReply(replyTo)(s => + StatusReply.error(CommandNotAvailable(command, s)) + ) + + private def unhandledEvent(event: ProofEvent, state: State): Nothing = + throw UnhandledEvent(event, state) diff --git a/domain/proof/command/entity/src/ProofBehaviour.scala b/domain/proof/command/entity/src/ProofBehaviour.scala deleted file mode 100644 index e8c7e4e..0000000 --- a/domain/proof/command/entity/src/ProofBehaviour.scala +++ /dev/null @@ -1,108 +0,0 @@ -package mdr.pdb -package proof -package command -package entity - -import akka.persistence.typed.scaladsl.EventSourcedBehavior -import akka.persistence.typed.PersistenceId -import akka.actor.typed.Behavior -import akka.actor.typed.ActorRef -import akka.pattern.StatusReply -import akka.Done - -import fiftyforms.akka.* -import akka.persistence.typed.scaladsl.Effect - -object ProofBehaviour: - - type ReplyTo = ActorRef[StatusReply[Done]] - - case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) - - type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] - - type ProofReplyEffect = - akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] - - def apply(persistenceId: PersistenceId): Behavior[ProofCommand] = - import ProofEventHandler.* - EventSourcedBehavior - .withEnforcedReplies[ProofCommand, ProofEvent, State]( - persistenceId = persistenceId, - emptyState = None, - commandHandler = handleProofCommand, - eventHandler = (state, event) => - state.handleEvent(event).orElse(unhandledEvent(event, state)) - ) - .withTagger(_ => Set(ProofEvent.Tag)) - - def handleProofCommand( - state: State, - command: ProofCommand - ): ProofReplyEffect = - handleCommand(state, command.command) match - case Some(events) => persist(events, command.meta, command.replyTo) - case _ => unhandled(command.command, command.replyTo) - - def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = - state match - case None => - cmd match - case CreateProof( - id, - person, - parameterId, - criterionId, - documents, - Authorized(note) - ) => - Some( - Seq( - ProofCreated(id, person, parameterId, criterionId, documents), - ProofAuthorized(id, note) - ) - ) - case CreateProof( - id, - person, - parameterId, - criterionId, - documents, - _ - ) => - Some( - Seq(ProofCreated(id, person, parameterId, criterionId, documents)) - ) - case _ => None - case Some(proof) if proof.isRevoked => None - case Some(proof) if proof.isAuthorized => - cmd match - case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) - case RevokeProof(id, reason, since, documents) => - Some(Seq((ProofRevoked(id, reason, since, documents)))) - case _ => None - case Some(proof) => - cmd match - case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) - case UpdateProof(id, documents) => - Some(Seq(ProofUpdated(id, documents))) - case RevokeProof(id, reason, since, documents) => - Some(Seq((ProofRevoked(id, reason, since, documents)))) - case _ => None - - private def persist( - events: Seq[Event], - meta: WW, - replyTo: ReplyTo - ): ProofReplyEffect = - Effect - .persist(events.map(ProofEvent(_, meta))) - .thenReply(replyTo)(_ => StatusReply.Ack) - - private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = - Effect.unhandled.thenReply(replyTo)(s => - StatusReply.error(CommandNotAvailable(command, s)) - ) - - private def unhandledEvent(event: ProofEvent, state: State): Nothing = - throw UnhandledEvent(event, state) diff --git a/domain/proof/command/entity/src/ProofCommandBus.scala b/domain/proof/command/entity/src/ProofCommandBus.scala new file mode 100644 index 0000000..3167e02 --- /dev/null +++ b/domain/proof/command/entity/src/ProofCommandBus.scala @@ -0,0 +1,50 @@ +package mdr.pdb +package proof +package command +package entity + +import zio.* +import akka.actor.typed.ActorSystem +import akka.cluster.sharding.typed.scaladsl.EntityContext +import akka.actor.typed.Behavior +import akka.cluster.sharding.typed.scaladsl.ClusterSharding +import akka.util.Timeout +import java.time.Instant + +object ProofCommandBus: + def submitCommand(command: Command): RIO[ProofCommandBus, Unit] = + ZIO.serviceWithZIO(_.submitCommand(command)) + + val layer: RLayer[ActorSystem[_], ProofCommandBus] = + ZIO + .serviceWithZIO[ActorSystem[_]](system => + for + timeout <- Task.attempt( + Timeout.create( + system.settings.config.getDuration("proof-bus.timeout") + ) + ) + // TODO: init only once + _ <- Task.attempt(ProofBehavior.init(system)) + yield ProofCommandBus(system)(using timeout) + ) + .toLayer + +class ProofCommandBus(system: ActorSystem[_])(using timeout: Timeout): + private val sharding = ClusterSharding(system) + + def submitCommand(command: Command): Task[Unit] = + for + entityRef <- Task.attempt( + sharding.entityRefFor(ProofBehavior.EntityKey, command.id) + ) + reply <- ZIO.fromFuture(_ => + entityRef.askWithStatus( + ProofBehavior.ProofCommand( + command, + WhoWhen(OsobniCislo("0123"), Instant.now()), + _ + ) + ) + ) + yield () diff --git a/domain/proof/command/json/src/Codecs.scala b/domain/proof/command/json/src/Codecs.scala deleted file mode 100644 index 6867615..0000000 --- a/domain/proof/command/json/src/Codecs.scala +++ /dev/null @@ -1,13 +0,0 @@ -package mdr.pdb -package proof.command -package json - -import zio.json.* - -trait Codecs extends proof.json.Codecs: - - given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen - given JsonCodec[CreateProof] = DeriveJsonCodec.gen - given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen - given JsonCodec[UpdateProof] = DeriveJsonCodec.gen - given JsonCodec[RevokeProof] = DeriveJsonCodec.gen diff --git a/build.sbt b/build.sbt index e6f5e40..227e292 100644 --- a/build.sbt +++ b/build.sbt @@ -14,38 +14,38 @@ lazy val proof = entityProject("proof", file("domain/proof")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .repo(_.dependsOn(`mongo-support`)) .entity(_.dependsOn(`akka-persistence-support`)) .projection(_.dependsOn(`akka-persistence-support`)) - .endpoints(_.dependsOn(`tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val parameters = entityProject("parameters", file("domain/parameters")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) - .endpoints(_.dependsOn(`tapir-support`)) + .codecs(_.dependsOn(codecs, `tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val users = entityProject("users", file("domain/users")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val core = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("core")) -lazy val json = crossProject(JSPlatform, JVMPlatform) +lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) - .in(file("json")) + .in(file("codecs")) .settings(IWDeps.zioJson) - .dependsOn(core) + .dependsOn(core, `tapir-support`) lazy val endpoints = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("endpoints")) - .dependsOn(core, json, `tapir-support`) + .dependsOn(core, codecs, `tapir-support`) // TODO: move all from fiftyforms to iterative works lazy val ui = (project in file("fiftyforms/ui")) @@ -80,7 +80,11 @@ lazy val `akka-persistence-support` = project .in(file("fiftyforms/akka-persistence")) - .settings(IWDeps.akka.profiles.eventsourcedJdbcProjection) + .settings( + IWDeps.akka.libs.persistence, + libraryDependencies += "com.typesafe.akka" %% "akka-cluster-sharding-typed" % IWDeps.akka.V, + IWDeps.akka.profiles.eventsourcedJdbcProjection + ) lazy val app = (project in file("app")) .enablePlugins(ScalaJSPlugin, VitePlugin) @@ -166,6 +170,7 @@ proof.query.api, proof.command.api, proof.query.projection, + proof.command.entity, endpoints.jvm ) diff --git a/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala new file mode 100644 index 0000000..22ebfb8 --- /dev/null +++ b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala @@ -0,0 +1,26 @@ +package mdr.pdb +package codecs + +import zio.json.* +import fiftyforms.tapir.CustomTapir + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + given JsonCodec[WhoWhen] = DeriveJsonCodec.gen + given JsonCodec[OsobniCislo] = + JsonCodec.string.transform(OsobniCislo.apply, _.toString) + given JsonFieldEncoder[OsobniCislo] = + JsonFieldEncoder.string.contramap(_.toString) + given JsonFieldDecoder[OsobniCislo] = + JsonFieldDecoder.string.map(OsobniCislo(_)) + +trait TapirCodecs extends CustomTapir: + given Schema[OsobniCislo] = Schema.string + given Codec.PlainCodec[OsobniCislo] = + Codec.string.mapDecode(OsobniCislo.apply andThen DecodeResult.Value.apply)( + _.toString + ) + given Schema[WhoWhen] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/parameters/shared/codecs/src/Codecs.scala b/domain/parameters/shared/codecs/src/Codecs.scala new file mode 100644 index 0000000..751e79b --- /dev/null +++ b/domain/parameters/shared/codecs/src/Codecs.scala @@ -0,0 +1,10 @@ +package mdr.pdb +package parameters +package codecs + +import zio.json.* + +trait Codecs: + + given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen + given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/parameters/shared/json/src/Codecs.scala b/domain/parameters/shared/json/src/Codecs.scala deleted file mode 100644 index b849072..0000000 --- a/domain/parameters/shared/json/src/Codecs.scala +++ /dev/null @@ -1,10 +0,0 @@ -package mdr.pdb -package parameters -package json - -import zio.json.* - -trait Codecs: - - given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen - given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/proof/command/api/src/ProofCommandApi.scala b/domain/proof/command/api/src/ProofCommandApi.scala new file mode 100644 index 0000000..12f747e --- /dev/null +++ b/domain/proof/command/api/src/ProofCommandApi.scala @@ -0,0 +1,16 @@ +package mdr.pdb.proof.command +package api + +import endpoints.Endpoints +import entity.ProofCommandBus +import fiftyforms.tapir.CustomTapir +import fiftyforms.tapir.InternalServerError + +object ProofCommandApi extends CustomTapir: + + val submitCommand: ZServerEndpoint[ProofCommandBus, Any] = + Endpoints.submitCommand.zServerLogic(cmd => + ProofCommandBus + .submitCommand(cmd) + .mapError(InternalServerError.fromThrowable) + ) diff --git a/domain/proof/command/codecs/src/Codecs.scala b/domain/proof/command/codecs/src/Codecs.scala new file mode 100644 index 0000000..0e75f82 --- /dev/null +++ b/domain/proof/command/codecs/src/Codecs.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof.command +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends proof.codecs.JsonCodecs: + + given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen + given JsonCodec[CreateProof] = DeriveJsonCodec.gen + given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen + given JsonCodec[UpdateProof] = DeriveJsonCodec.gen + given JsonCodec[RevokeProof] = DeriveJsonCodec.gen + given JsonCodec[Command] = DeriveJsonCodec.gen + +trait TapirCodecs extends proof.codecs.TapirCodecs: + + given Schema[AuthorizeOption] = Schema.derived + given Schema[CreateProof] = Schema.derived + given Schema[AuthorizeProof] = Schema.derived + given Schema[UpdateProof] = Schema.derived + given Schema[RevokeProof] = Schema.derived + given Schema[Command] = Schema.derived diff --git a/domain/proof/command/endpoints/src/Endpoints.scala b/domain/proof/command/endpoints/src/Endpoints.scala new file mode 100644 index 0000000..dcd114e --- /dev/null +++ b/domain/proof/command/endpoints/src/Endpoints.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof +package command +package endpoints + +import fiftyforms.tapir.CustomTapir +import mdr.pdb.proof.command.codecs.Codecs +import fiftyforms.tapir.ServerError +import zio.json.JsonCodec +import zio.json.DeriveJsonCodec +import sttp.model.StatusCode + +object Endpoints + extends mdr.pdb.endpoints.Endpoints + with CustomTapir + with Codecs: + + val submitCommand: Endpoint[Unit, Command, ServerError, Unit, Any] = + endpoint + .in("command") + .in("proof") + .post + .in(jsonBody[Command]) + .out(statusCode(StatusCode.Accepted)) + .errorOut(jsonBody[ServerError]) diff --git a/domain/proof/command/entity/src/ProofBehavior.scala b/domain/proof/command/entity/src/ProofBehavior.scala new file mode 100644 index 0000000..f679981 --- /dev/null +++ b/domain/proof/command/entity/src/ProofBehavior.scala @@ -0,0 +1,120 @@ +package mdr.pdb +package proof +package command +package entity + +import akka.persistence.typed.scaladsl.Effect +import akka.persistence.typed.scaladsl.EventSourcedBehavior +import akka.persistence.typed.PersistenceId +import akka.actor.typed.Behavior +import akka.actor.typed.ActorRef +import akka.pattern.StatusReply +import akka.Done + +import fiftyforms.akka.* +import akka.actor.typed.ActorSystem +import akka.cluster.sharding.typed.scaladsl.EntityTypeKey +import akka.cluster.sharding.typed.scaladsl.EntityContext +import akka.cluster.sharding.typed.scaladsl.ClusterSharding +import akka.cluster.sharding.typed.scaladsl.Entity + +object ProofBehavior: + + type ReplyTo = ActorRef[StatusReply[Done]] + + case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) + + type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] + + type ProofReplyEffect = + akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] + + val EntityKey: EntityTypeKey[ProofCommand] = EntityTypeKey("Proof") + + def init(system: ActorSystem[_]): Unit = + val behaviorFactory: EntityContext[ProofCommand] => Behavior[ProofCommand] = + entityContext => ProofBehavior(entityContext.entityId) + ClusterSharding(system).init(Entity(EntityKey)(behaviorFactory)) + + def apply(persistenceId: String): Behavior[ProofCommand] = + import ProofEventHandler.* + EventSourcedBehavior + .withEnforcedReplies[ProofCommand, ProofEvent, State]( + persistenceId = PersistenceId.ofUniqueId(persistenceId), + emptyState = None, + commandHandler = handleProofCommand, + eventHandler = (state, event) => + state.handleEvent(event).orElse(unhandledEvent(event, state)) + ) + .withTagger(_ => Set(ProofEvent.Tag)) + + def handleProofCommand( + state: State, + command: ProofCommand + ): ProofReplyEffect = + handleCommand(state, command.command) match + case Some(events) => persist(events, command.meta, command.replyTo) + case _ => unhandled(command.command, command.replyTo) + + def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = + state match + case None => + cmd match + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + Authorized(note) + ) => + Some( + Seq( + ProofCreated(id, person, parameterId, criterionId, documents), + ProofAuthorized(id, note) + ) + ) + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + _ + ) => + Some( + Seq(ProofCreated(id, person, parameterId, criterionId, documents)) + ) + case _ => None + case Some(proof) if proof.isRevoked => None + case Some(proof) if proof.isAuthorized => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + case Some(proof) => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case UpdateProof(id, documents) => + Some(Seq(ProofUpdated(id, documents))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + + private def persist( + events: Seq[Event], + meta: WW, + replyTo: ReplyTo + ): ProofReplyEffect = + Effect + .persist(events.map(ProofEvent(_, meta))) + .thenReply(replyTo)(_ => StatusReply.Ack) + + private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = + Effect.unhandled.thenReply(replyTo)(s => + StatusReply.error(CommandNotAvailable(command, s)) + ) + + private def unhandledEvent(event: ProofEvent, state: State): Nothing = + throw UnhandledEvent(event, state) diff --git a/domain/proof/command/entity/src/ProofBehaviour.scala b/domain/proof/command/entity/src/ProofBehaviour.scala deleted file mode 100644 index e8c7e4e..0000000 --- a/domain/proof/command/entity/src/ProofBehaviour.scala +++ /dev/null @@ -1,108 +0,0 @@ -package mdr.pdb -package proof -package command -package entity - -import akka.persistence.typed.scaladsl.EventSourcedBehavior -import akka.persistence.typed.PersistenceId -import akka.actor.typed.Behavior -import akka.actor.typed.ActorRef -import akka.pattern.StatusReply -import akka.Done - -import fiftyforms.akka.* -import akka.persistence.typed.scaladsl.Effect - -object ProofBehaviour: - - type ReplyTo = ActorRef[StatusReply[Done]] - - case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) - - type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] - - type ProofReplyEffect = - akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] - - def apply(persistenceId: PersistenceId): Behavior[ProofCommand] = - import ProofEventHandler.* - EventSourcedBehavior - .withEnforcedReplies[ProofCommand, ProofEvent, State]( - persistenceId = persistenceId, - emptyState = None, - commandHandler = handleProofCommand, - eventHandler = (state, event) => - state.handleEvent(event).orElse(unhandledEvent(event, state)) - ) - .withTagger(_ => Set(ProofEvent.Tag)) - - def handleProofCommand( - state: State, - command: ProofCommand - ): ProofReplyEffect = - handleCommand(state, command.command) match - case Some(events) => persist(events, command.meta, command.replyTo) - case _ => unhandled(command.command, command.replyTo) - - def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = - state match - case None => - cmd match - case CreateProof( - id, - person, - parameterId, - criterionId, - documents, - Authorized(note) - ) => - Some( - Seq( - ProofCreated(id, person, parameterId, criterionId, documents), - ProofAuthorized(id, note) - ) - ) - case CreateProof( - id, - person, - parameterId, - criterionId, - documents, - _ - ) => - Some( - Seq(ProofCreated(id, person, parameterId, criterionId, documents)) - ) - case _ => None - case Some(proof) if proof.isRevoked => None - case Some(proof) if proof.isAuthorized => - cmd match - case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) - case RevokeProof(id, reason, since, documents) => - Some(Seq((ProofRevoked(id, reason, since, documents)))) - case _ => None - case Some(proof) => - cmd match - case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) - case UpdateProof(id, documents) => - Some(Seq(ProofUpdated(id, documents))) - case RevokeProof(id, reason, since, documents) => - Some(Seq((ProofRevoked(id, reason, since, documents)))) - case _ => None - - private def persist( - events: Seq[Event], - meta: WW, - replyTo: ReplyTo - ): ProofReplyEffect = - Effect - .persist(events.map(ProofEvent(_, meta))) - .thenReply(replyTo)(_ => StatusReply.Ack) - - private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = - Effect.unhandled.thenReply(replyTo)(s => - StatusReply.error(CommandNotAvailable(command, s)) - ) - - private def unhandledEvent(event: ProofEvent, state: State): Nothing = - throw UnhandledEvent(event, state) diff --git a/domain/proof/command/entity/src/ProofCommandBus.scala b/domain/proof/command/entity/src/ProofCommandBus.scala new file mode 100644 index 0000000..3167e02 --- /dev/null +++ b/domain/proof/command/entity/src/ProofCommandBus.scala @@ -0,0 +1,50 @@ +package mdr.pdb +package proof +package command +package entity + +import zio.* +import akka.actor.typed.ActorSystem +import akka.cluster.sharding.typed.scaladsl.EntityContext +import akka.actor.typed.Behavior +import akka.cluster.sharding.typed.scaladsl.ClusterSharding +import akka.util.Timeout +import java.time.Instant + +object ProofCommandBus: + def submitCommand(command: Command): RIO[ProofCommandBus, Unit] = + ZIO.serviceWithZIO(_.submitCommand(command)) + + val layer: RLayer[ActorSystem[_], ProofCommandBus] = + ZIO + .serviceWithZIO[ActorSystem[_]](system => + for + timeout <- Task.attempt( + Timeout.create( + system.settings.config.getDuration("proof-bus.timeout") + ) + ) + // TODO: init only once + _ <- Task.attempt(ProofBehavior.init(system)) + yield ProofCommandBus(system)(using timeout) + ) + .toLayer + +class ProofCommandBus(system: ActorSystem[_])(using timeout: Timeout): + private val sharding = ClusterSharding(system) + + def submitCommand(command: Command): Task[Unit] = + for + entityRef <- Task.attempt( + sharding.entityRefFor(ProofBehavior.EntityKey, command.id) + ) + reply <- ZIO.fromFuture(_ => + entityRef.askWithStatus( + ProofBehavior.ProofCommand( + command, + WhoWhen(OsobniCislo("0123"), Instant.now()), + _ + ) + ) + ) + yield () diff --git a/domain/proof/command/json/src/Codecs.scala b/domain/proof/command/json/src/Codecs.scala deleted file mode 100644 index 6867615..0000000 --- a/domain/proof/command/json/src/Codecs.scala +++ /dev/null @@ -1,13 +0,0 @@ -package mdr.pdb -package proof.command -package json - -import zio.json.* - -trait Codecs extends proof.json.Codecs: - - given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen - given JsonCodec[CreateProof] = DeriveJsonCodec.gen - given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen - given JsonCodec[UpdateProof] = DeriveJsonCodec.gen - given JsonCodec[RevokeProof] = DeriveJsonCodec.gen diff --git a/domain/proof/command/model/src/Command.scala b/domain/proof/command/model/src/Command.scala index 35a15af..1bff3cd 100644 --- a/domain/proof/command/model/src/Command.scala +++ b/domain/proof/command/model/src/Command.scala @@ -5,7 +5,8 @@ import java.time.LocalDate import java.time.Instant -sealed trait Command +sealed trait Command: + def id: Proof.Id sealed trait AuthorizeOption case object Unauthorized extends AuthorizeOption diff --git a/build.sbt b/build.sbt index e6f5e40..227e292 100644 --- a/build.sbt +++ b/build.sbt @@ -14,38 +14,38 @@ lazy val proof = entityProject("proof", file("domain/proof")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .repo(_.dependsOn(`mongo-support`)) .entity(_.dependsOn(`akka-persistence-support`)) .projection(_.dependsOn(`akka-persistence-support`)) - .endpoints(_.dependsOn(`tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val parameters = entityProject("parameters", file("domain/parameters")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) - .endpoints(_.dependsOn(`tapir-support`)) + .codecs(_.dependsOn(codecs, `tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val users = entityProject("users", file("domain/users")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val core = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("core")) -lazy val json = crossProject(JSPlatform, JVMPlatform) +lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) - .in(file("json")) + .in(file("codecs")) .settings(IWDeps.zioJson) - .dependsOn(core) + .dependsOn(core, `tapir-support`) lazy val endpoints = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("endpoints")) - .dependsOn(core, json, `tapir-support`) + .dependsOn(core, codecs, `tapir-support`) // TODO: move all from fiftyforms to iterative works lazy val ui = (project in file("fiftyforms/ui")) @@ -80,7 +80,11 @@ lazy val `akka-persistence-support` = project .in(file("fiftyforms/akka-persistence")) - .settings(IWDeps.akka.profiles.eventsourcedJdbcProjection) + .settings( + IWDeps.akka.libs.persistence, + libraryDependencies += "com.typesafe.akka" %% "akka-cluster-sharding-typed" % IWDeps.akka.V, + IWDeps.akka.profiles.eventsourcedJdbcProjection + ) lazy val app = (project in file("app")) .enablePlugins(ScalaJSPlugin, VitePlugin) @@ -166,6 +170,7 @@ proof.query.api, proof.command.api, proof.query.projection, + proof.command.entity, endpoints.jvm ) diff --git a/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala new file mode 100644 index 0000000..22ebfb8 --- /dev/null +++ b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala @@ -0,0 +1,26 @@ +package mdr.pdb +package codecs + +import zio.json.* +import fiftyforms.tapir.CustomTapir + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + given JsonCodec[WhoWhen] = DeriveJsonCodec.gen + given JsonCodec[OsobniCislo] = + JsonCodec.string.transform(OsobniCislo.apply, _.toString) + given JsonFieldEncoder[OsobniCislo] = + JsonFieldEncoder.string.contramap(_.toString) + given JsonFieldDecoder[OsobniCislo] = + JsonFieldDecoder.string.map(OsobniCislo(_)) + +trait TapirCodecs extends CustomTapir: + given Schema[OsobniCislo] = Schema.string + given Codec.PlainCodec[OsobniCislo] = + Codec.string.mapDecode(OsobniCislo.apply andThen DecodeResult.Value.apply)( + _.toString + ) + given Schema[WhoWhen] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/parameters/shared/codecs/src/Codecs.scala b/domain/parameters/shared/codecs/src/Codecs.scala new file mode 100644 index 0000000..751e79b --- /dev/null +++ b/domain/parameters/shared/codecs/src/Codecs.scala @@ -0,0 +1,10 @@ +package mdr.pdb +package parameters +package codecs + +import zio.json.* + +trait Codecs: + + given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen + given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/parameters/shared/json/src/Codecs.scala b/domain/parameters/shared/json/src/Codecs.scala deleted file mode 100644 index b849072..0000000 --- a/domain/parameters/shared/json/src/Codecs.scala +++ /dev/null @@ -1,10 +0,0 @@ -package mdr.pdb -package parameters -package json - -import zio.json.* - -trait Codecs: - - given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen - given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/proof/command/api/src/ProofCommandApi.scala b/domain/proof/command/api/src/ProofCommandApi.scala new file mode 100644 index 0000000..12f747e --- /dev/null +++ b/domain/proof/command/api/src/ProofCommandApi.scala @@ -0,0 +1,16 @@ +package mdr.pdb.proof.command +package api + +import endpoints.Endpoints +import entity.ProofCommandBus +import fiftyforms.tapir.CustomTapir +import fiftyforms.tapir.InternalServerError + +object ProofCommandApi extends CustomTapir: + + val submitCommand: ZServerEndpoint[ProofCommandBus, Any] = + Endpoints.submitCommand.zServerLogic(cmd => + ProofCommandBus + .submitCommand(cmd) + .mapError(InternalServerError.fromThrowable) + ) diff --git a/domain/proof/command/codecs/src/Codecs.scala b/domain/proof/command/codecs/src/Codecs.scala new file mode 100644 index 0000000..0e75f82 --- /dev/null +++ b/domain/proof/command/codecs/src/Codecs.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof.command +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends proof.codecs.JsonCodecs: + + given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen + given JsonCodec[CreateProof] = DeriveJsonCodec.gen + given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen + given JsonCodec[UpdateProof] = DeriveJsonCodec.gen + given JsonCodec[RevokeProof] = DeriveJsonCodec.gen + given JsonCodec[Command] = DeriveJsonCodec.gen + +trait TapirCodecs extends proof.codecs.TapirCodecs: + + given Schema[AuthorizeOption] = Schema.derived + given Schema[CreateProof] = Schema.derived + given Schema[AuthorizeProof] = Schema.derived + given Schema[UpdateProof] = Schema.derived + given Schema[RevokeProof] = Schema.derived + given Schema[Command] = Schema.derived diff --git a/domain/proof/command/endpoints/src/Endpoints.scala b/domain/proof/command/endpoints/src/Endpoints.scala new file mode 100644 index 0000000..dcd114e --- /dev/null +++ b/domain/proof/command/endpoints/src/Endpoints.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof +package command +package endpoints + +import fiftyforms.tapir.CustomTapir +import mdr.pdb.proof.command.codecs.Codecs +import fiftyforms.tapir.ServerError +import zio.json.JsonCodec +import zio.json.DeriveJsonCodec +import sttp.model.StatusCode + +object Endpoints + extends mdr.pdb.endpoints.Endpoints + with CustomTapir + with Codecs: + + val submitCommand: Endpoint[Unit, Command, ServerError, Unit, Any] = + endpoint + .in("command") + .in("proof") + .post + .in(jsonBody[Command]) + .out(statusCode(StatusCode.Accepted)) + .errorOut(jsonBody[ServerError]) diff --git a/domain/proof/command/entity/src/ProofBehavior.scala b/domain/proof/command/entity/src/ProofBehavior.scala new file mode 100644 index 0000000..f679981 --- /dev/null +++ b/domain/proof/command/entity/src/ProofBehavior.scala @@ -0,0 +1,120 @@ +package mdr.pdb +package proof +package command +package entity + +import akka.persistence.typed.scaladsl.Effect +import akka.persistence.typed.scaladsl.EventSourcedBehavior +import akka.persistence.typed.PersistenceId +import akka.actor.typed.Behavior +import akka.actor.typed.ActorRef +import akka.pattern.StatusReply +import akka.Done + +import fiftyforms.akka.* +import akka.actor.typed.ActorSystem +import akka.cluster.sharding.typed.scaladsl.EntityTypeKey +import akka.cluster.sharding.typed.scaladsl.EntityContext +import akka.cluster.sharding.typed.scaladsl.ClusterSharding +import akka.cluster.sharding.typed.scaladsl.Entity + +object ProofBehavior: + + type ReplyTo = ActorRef[StatusReply[Done]] + + case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) + + type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] + + type ProofReplyEffect = + akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] + + val EntityKey: EntityTypeKey[ProofCommand] = EntityTypeKey("Proof") + + def init(system: ActorSystem[_]): Unit = + val behaviorFactory: EntityContext[ProofCommand] => Behavior[ProofCommand] = + entityContext => ProofBehavior(entityContext.entityId) + ClusterSharding(system).init(Entity(EntityKey)(behaviorFactory)) + + def apply(persistenceId: String): Behavior[ProofCommand] = + import ProofEventHandler.* + EventSourcedBehavior + .withEnforcedReplies[ProofCommand, ProofEvent, State]( + persistenceId = PersistenceId.ofUniqueId(persistenceId), + emptyState = None, + commandHandler = handleProofCommand, + eventHandler = (state, event) => + state.handleEvent(event).orElse(unhandledEvent(event, state)) + ) + .withTagger(_ => Set(ProofEvent.Tag)) + + def handleProofCommand( + state: State, + command: ProofCommand + ): ProofReplyEffect = + handleCommand(state, command.command) match + case Some(events) => persist(events, command.meta, command.replyTo) + case _ => unhandled(command.command, command.replyTo) + + def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = + state match + case None => + cmd match + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + Authorized(note) + ) => + Some( + Seq( + ProofCreated(id, person, parameterId, criterionId, documents), + ProofAuthorized(id, note) + ) + ) + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + _ + ) => + Some( + Seq(ProofCreated(id, person, parameterId, criterionId, documents)) + ) + case _ => None + case Some(proof) if proof.isRevoked => None + case Some(proof) if proof.isAuthorized => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + case Some(proof) => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case UpdateProof(id, documents) => + Some(Seq(ProofUpdated(id, documents))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + + private def persist( + events: Seq[Event], + meta: WW, + replyTo: ReplyTo + ): ProofReplyEffect = + Effect + .persist(events.map(ProofEvent(_, meta))) + .thenReply(replyTo)(_ => StatusReply.Ack) + + private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = + Effect.unhandled.thenReply(replyTo)(s => + StatusReply.error(CommandNotAvailable(command, s)) + ) + + private def unhandledEvent(event: ProofEvent, state: State): Nothing = + throw UnhandledEvent(event, state) diff --git a/domain/proof/command/entity/src/ProofBehaviour.scala b/domain/proof/command/entity/src/ProofBehaviour.scala deleted file mode 100644 index e8c7e4e..0000000 --- a/domain/proof/command/entity/src/ProofBehaviour.scala +++ /dev/null @@ -1,108 +0,0 @@ -package mdr.pdb -package proof -package command -package entity - -import akka.persistence.typed.scaladsl.EventSourcedBehavior -import akka.persistence.typed.PersistenceId -import akka.actor.typed.Behavior -import akka.actor.typed.ActorRef -import akka.pattern.StatusReply -import akka.Done - -import fiftyforms.akka.* -import akka.persistence.typed.scaladsl.Effect - -object ProofBehaviour: - - type ReplyTo = ActorRef[StatusReply[Done]] - - case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) - - type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] - - type ProofReplyEffect = - akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] - - def apply(persistenceId: PersistenceId): Behavior[ProofCommand] = - import ProofEventHandler.* - EventSourcedBehavior - .withEnforcedReplies[ProofCommand, ProofEvent, State]( - persistenceId = persistenceId, - emptyState = None, - commandHandler = handleProofCommand, - eventHandler = (state, event) => - state.handleEvent(event).orElse(unhandledEvent(event, state)) - ) - .withTagger(_ => Set(ProofEvent.Tag)) - - def handleProofCommand( - state: State, - command: ProofCommand - ): ProofReplyEffect = - handleCommand(state, command.command) match - case Some(events) => persist(events, command.meta, command.replyTo) - case _ => unhandled(command.command, command.replyTo) - - def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = - state match - case None => - cmd match - case CreateProof( - id, - person, - parameterId, - criterionId, - documents, - Authorized(note) - ) => - Some( - Seq( - ProofCreated(id, person, parameterId, criterionId, documents), - ProofAuthorized(id, note) - ) - ) - case CreateProof( - id, - person, - parameterId, - criterionId, - documents, - _ - ) => - Some( - Seq(ProofCreated(id, person, parameterId, criterionId, documents)) - ) - case _ => None - case Some(proof) if proof.isRevoked => None - case Some(proof) if proof.isAuthorized => - cmd match - case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) - case RevokeProof(id, reason, since, documents) => - Some(Seq((ProofRevoked(id, reason, since, documents)))) - case _ => None - case Some(proof) => - cmd match - case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) - case UpdateProof(id, documents) => - Some(Seq(ProofUpdated(id, documents))) - case RevokeProof(id, reason, since, documents) => - Some(Seq((ProofRevoked(id, reason, since, documents)))) - case _ => None - - private def persist( - events: Seq[Event], - meta: WW, - replyTo: ReplyTo - ): ProofReplyEffect = - Effect - .persist(events.map(ProofEvent(_, meta))) - .thenReply(replyTo)(_ => StatusReply.Ack) - - private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = - Effect.unhandled.thenReply(replyTo)(s => - StatusReply.error(CommandNotAvailable(command, s)) - ) - - private def unhandledEvent(event: ProofEvent, state: State): Nothing = - throw UnhandledEvent(event, state) diff --git a/domain/proof/command/entity/src/ProofCommandBus.scala b/domain/proof/command/entity/src/ProofCommandBus.scala new file mode 100644 index 0000000..3167e02 --- /dev/null +++ b/domain/proof/command/entity/src/ProofCommandBus.scala @@ -0,0 +1,50 @@ +package mdr.pdb +package proof +package command +package entity + +import zio.* +import akka.actor.typed.ActorSystem +import akka.cluster.sharding.typed.scaladsl.EntityContext +import akka.actor.typed.Behavior +import akka.cluster.sharding.typed.scaladsl.ClusterSharding +import akka.util.Timeout +import java.time.Instant + +object ProofCommandBus: + def submitCommand(command: Command): RIO[ProofCommandBus, Unit] = + ZIO.serviceWithZIO(_.submitCommand(command)) + + val layer: RLayer[ActorSystem[_], ProofCommandBus] = + ZIO + .serviceWithZIO[ActorSystem[_]](system => + for + timeout <- Task.attempt( + Timeout.create( + system.settings.config.getDuration("proof-bus.timeout") + ) + ) + // TODO: init only once + _ <- Task.attempt(ProofBehavior.init(system)) + yield ProofCommandBus(system)(using timeout) + ) + .toLayer + +class ProofCommandBus(system: ActorSystem[_])(using timeout: Timeout): + private val sharding = ClusterSharding(system) + + def submitCommand(command: Command): Task[Unit] = + for + entityRef <- Task.attempt( + sharding.entityRefFor(ProofBehavior.EntityKey, command.id) + ) + reply <- ZIO.fromFuture(_ => + entityRef.askWithStatus( + ProofBehavior.ProofCommand( + command, + WhoWhen(OsobniCislo("0123"), Instant.now()), + _ + ) + ) + ) + yield () diff --git a/domain/proof/command/json/src/Codecs.scala b/domain/proof/command/json/src/Codecs.scala deleted file mode 100644 index 6867615..0000000 --- a/domain/proof/command/json/src/Codecs.scala +++ /dev/null @@ -1,13 +0,0 @@ -package mdr.pdb -package proof.command -package json - -import zio.json.* - -trait Codecs extends proof.json.Codecs: - - given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen - given JsonCodec[CreateProof] = DeriveJsonCodec.gen - given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen - given JsonCodec[UpdateProof] = DeriveJsonCodec.gen - given JsonCodec[RevokeProof] = DeriveJsonCodec.gen diff --git a/domain/proof/command/model/src/Command.scala b/domain/proof/command/model/src/Command.scala index 35a15af..1bff3cd 100644 --- a/domain/proof/command/model/src/Command.scala +++ b/domain/proof/command/model/src/Command.scala @@ -5,7 +5,8 @@ import java.time.LocalDate import java.time.Instant -sealed trait Command +sealed trait Command: + def id: Proof.Id sealed trait AuthorizeOption case object Unauthorized extends AuthorizeOption diff --git a/domain/proof/query/api/src/ProofQueryApi.scala b/domain/proof/query/api/src/ProofQueryApi.scala new file mode 100644 index 0000000..19acd07 --- /dev/null +++ b/domain/proof/query/api/src/ProofQueryApi.scala @@ -0,0 +1,16 @@ +package mdr.pdb.proof.query +package api + +import endpoints.Endpoints +import repo.ProofRepository +import fiftyforms.tapir.CustomTapir +import fiftyforms.tapir.InternalServerError + +object ProofQueryApi extends CustomTapir: + + val forUser: ZServerEndpoint[ProofRepository, Any] = + Endpoints.forUser.zServerLogic(osc => + ProofRepository + .matching(ProofRepository.OfPerson(osc)) + .mapError(InternalServerError.fromThrowable) + ) diff --git a/build.sbt b/build.sbt index e6f5e40..227e292 100644 --- a/build.sbt +++ b/build.sbt @@ -14,38 +14,38 @@ lazy val proof = entityProject("proof", file("domain/proof")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .repo(_.dependsOn(`mongo-support`)) .entity(_.dependsOn(`akka-persistence-support`)) .projection(_.dependsOn(`akka-persistence-support`)) - .endpoints(_.dependsOn(`tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val parameters = entityProject("parameters", file("domain/parameters")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) - .endpoints(_.dependsOn(`tapir-support`)) + .codecs(_.dependsOn(codecs, `tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val users = entityProject("users", file("domain/users")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val core = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("core")) -lazy val json = crossProject(JSPlatform, JVMPlatform) +lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) - .in(file("json")) + .in(file("codecs")) .settings(IWDeps.zioJson) - .dependsOn(core) + .dependsOn(core, `tapir-support`) lazy val endpoints = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("endpoints")) - .dependsOn(core, json, `tapir-support`) + .dependsOn(core, codecs, `tapir-support`) // TODO: move all from fiftyforms to iterative works lazy val ui = (project in file("fiftyforms/ui")) @@ -80,7 +80,11 @@ lazy val `akka-persistence-support` = project .in(file("fiftyforms/akka-persistence")) - .settings(IWDeps.akka.profiles.eventsourcedJdbcProjection) + .settings( + IWDeps.akka.libs.persistence, + libraryDependencies += "com.typesafe.akka" %% "akka-cluster-sharding-typed" % IWDeps.akka.V, + IWDeps.akka.profiles.eventsourcedJdbcProjection + ) lazy val app = (project in file("app")) .enablePlugins(ScalaJSPlugin, VitePlugin) @@ -166,6 +170,7 @@ proof.query.api, proof.command.api, proof.query.projection, + proof.command.entity, endpoints.jvm ) diff --git a/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala new file mode 100644 index 0000000..22ebfb8 --- /dev/null +++ b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala @@ -0,0 +1,26 @@ +package mdr.pdb +package codecs + +import zio.json.* +import fiftyforms.tapir.CustomTapir + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + given JsonCodec[WhoWhen] = DeriveJsonCodec.gen + given JsonCodec[OsobniCislo] = + JsonCodec.string.transform(OsobniCislo.apply, _.toString) + given JsonFieldEncoder[OsobniCislo] = + JsonFieldEncoder.string.contramap(_.toString) + given JsonFieldDecoder[OsobniCislo] = + JsonFieldDecoder.string.map(OsobniCislo(_)) + +trait TapirCodecs extends CustomTapir: + given Schema[OsobniCislo] = Schema.string + given Codec.PlainCodec[OsobniCislo] = + Codec.string.mapDecode(OsobniCislo.apply andThen DecodeResult.Value.apply)( + _.toString + ) + given Schema[WhoWhen] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/parameters/shared/codecs/src/Codecs.scala b/domain/parameters/shared/codecs/src/Codecs.scala new file mode 100644 index 0000000..751e79b --- /dev/null +++ b/domain/parameters/shared/codecs/src/Codecs.scala @@ -0,0 +1,10 @@ +package mdr.pdb +package parameters +package codecs + +import zio.json.* + +trait Codecs: + + given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen + given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/parameters/shared/json/src/Codecs.scala b/domain/parameters/shared/json/src/Codecs.scala deleted file mode 100644 index b849072..0000000 --- a/domain/parameters/shared/json/src/Codecs.scala +++ /dev/null @@ -1,10 +0,0 @@ -package mdr.pdb -package parameters -package json - -import zio.json.* - -trait Codecs: - - given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen - given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/proof/command/api/src/ProofCommandApi.scala b/domain/proof/command/api/src/ProofCommandApi.scala new file mode 100644 index 0000000..12f747e --- /dev/null +++ b/domain/proof/command/api/src/ProofCommandApi.scala @@ -0,0 +1,16 @@ +package mdr.pdb.proof.command +package api + +import endpoints.Endpoints +import entity.ProofCommandBus +import fiftyforms.tapir.CustomTapir +import fiftyforms.tapir.InternalServerError + +object ProofCommandApi extends CustomTapir: + + val submitCommand: ZServerEndpoint[ProofCommandBus, Any] = + Endpoints.submitCommand.zServerLogic(cmd => + ProofCommandBus + .submitCommand(cmd) + .mapError(InternalServerError.fromThrowable) + ) diff --git a/domain/proof/command/codecs/src/Codecs.scala b/domain/proof/command/codecs/src/Codecs.scala new file mode 100644 index 0000000..0e75f82 --- /dev/null +++ b/domain/proof/command/codecs/src/Codecs.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof.command +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends proof.codecs.JsonCodecs: + + given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen + given JsonCodec[CreateProof] = DeriveJsonCodec.gen + given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen + given JsonCodec[UpdateProof] = DeriveJsonCodec.gen + given JsonCodec[RevokeProof] = DeriveJsonCodec.gen + given JsonCodec[Command] = DeriveJsonCodec.gen + +trait TapirCodecs extends proof.codecs.TapirCodecs: + + given Schema[AuthorizeOption] = Schema.derived + given Schema[CreateProof] = Schema.derived + given Schema[AuthorizeProof] = Schema.derived + given Schema[UpdateProof] = Schema.derived + given Schema[RevokeProof] = Schema.derived + given Schema[Command] = Schema.derived diff --git a/domain/proof/command/endpoints/src/Endpoints.scala b/domain/proof/command/endpoints/src/Endpoints.scala new file mode 100644 index 0000000..dcd114e --- /dev/null +++ b/domain/proof/command/endpoints/src/Endpoints.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof +package command +package endpoints + +import fiftyforms.tapir.CustomTapir +import mdr.pdb.proof.command.codecs.Codecs +import fiftyforms.tapir.ServerError +import zio.json.JsonCodec +import zio.json.DeriveJsonCodec +import sttp.model.StatusCode + +object Endpoints + extends mdr.pdb.endpoints.Endpoints + with CustomTapir + with Codecs: + + val submitCommand: Endpoint[Unit, Command, ServerError, Unit, Any] = + endpoint + .in("command") + .in("proof") + .post + .in(jsonBody[Command]) + .out(statusCode(StatusCode.Accepted)) + .errorOut(jsonBody[ServerError]) diff --git a/domain/proof/command/entity/src/ProofBehavior.scala b/domain/proof/command/entity/src/ProofBehavior.scala new file mode 100644 index 0000000..f679981 --- /dev/null +++ b/domain/proof/command/entity/src/ProofBehavior.scala @@ -0,0 +1,120 @@ +package mdr.pdb +package proof +package command +package entity + +import akka.persistence.typed.scaladsl.Effect +import akka.persistence.typed.scaladsl.EventSourcedBehavior +import akka.persistence.typed.PersistenceId +import akka.actor.typed.Behavior +import akka.actor.typed.ActorRef +import akka.pattern.StatusReply +import akka.Done + +import fiftyforms.akka.* +import akka.actor.typed.ActorSystem +import akka.cluster.sharding.typed.scaladsl.EntityTypeKey +import akka.cluster.sharding.typed.scaladsl.EntityContext +import akka.cluster.sharding.typed.scaladsl.ClusterSharding +import akka.cluster.sharding.typed.scaladsl.Entity + +object ProofBehavior: + + type ReplyTo = ActorRef[StatusReply[Done]] + + case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) + + type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] + + type ProofReplyEffect = + akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] + + val EntityKey: EntityTypeKey[ProofCommand] = EntityTypeKey("Proof") + + def init(system: ActorSystem[_]): Unit = + val behaviorFactory: EntityContext[ProofCommand] => Behavior[ProofCommand] = + entityContext => ProofBehavior(entityContext.entityId) + ClusterSharding(system).init(Entity(EntityKey)(behaviorFactory)) + + def apply(persistenceId: String): Behavior[ProofCommand] = + import ProofEventHandler.* + EventSourcedBehavior + .withEnforcedReplies[ProofCommand, ProofEvent, State]( + persistenceId = PersistenceId.ofUniqueId(persistenceId), + emptyState = None, + commandHandler = handleProofCommand, + eventHandler = (state, event) => + state.handleEvent(event).orElse(unhandledEvent(event, state)) + ) + .withTagger(_ => Set(ProofEvent.Tag)) + + def handleProofCommand( + state: State, + command: ProofCommand + ): ProofReplyEffect = + handleCommand(state, command.command) match + case Some(events) => persist(events, command.meta, command.replyTo) + case _ => unhandled(command.command, command.replyTo) + + def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = + state match + case None => + cmd match + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + Authorized(note) + ) => + Some( + Seq( + ProofCreated(id, person, parameterId, criterionId, documents), + ProofAuthorized(id, note) + ) + ) + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + _ + ) => + Some( + Seq(ProofCreated(id, person, parameterId, criterionId, documents)) + ) + case _ => None + case Some(proof) if proof.isRevoked => None + case Some(proof) if proof.isAuthorized => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + case Some(proof) => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case UpdateProof(id, documents) => + Some(Seq(ProofUpdated(id, documents))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + + private def persist( + events: Seq[Event], + meta: WW, + replyTo: ReplyTo + ): ProofReplyEffect = + Effect + .persist(events.map(ProofEvent(_, meta))) + .thenReply(replyTo)(_ => StatusReply.Ack) + + private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = + Effect.unhandled.thenReply(replyTo)(s => + StatusReply.error(CommandNotAvailable(command, s)) + ) + + private def unhandledEvent(event: ProofEvent, state: State): Nothing = + throw UnhandledEvent(event, state) diff --git a/domain/proof/command/entity/src/ProofBehaviour.scala b/domain/proof/command/entity/src/ProofBehaviour.scala deleted file mode 100644 index e8c7e4e..0000000 --- a/domain/proof/command/entity/src/ProofBehaviour.scala +++ /dev/null @@ -1,108 +0,0 @@ -package mdr.pdb -package proof -package command -package entity - -import akka.persistence.typed.scaladsl.EventSourcedBehavior -import akka.persistence.typed.PersistenceId -import akka.actor.typed.Behavior -import akka.actor.typed.ActorRef -import akka.pattern.StatusReply -import akka.Done - -import fiftyforms.akka.* -import akka.persistence.typed.scaladsl.Effect - -object ProofBehaviour: - - type ReplyTo = ActorRef[StatusReply[Done]] - - case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) - - type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] - - type ProofReplyEffect = - akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] - - def apply(persistenceId: PersistenceId): Behavior[ProofCommand] = - import ProofEventHandler.* - EventSourcedBehavior - .withEnforcedReplies[ProofCommand, ProofEvent, State]( - persistenceId = persistenceId, - emptyState = None, - commandHandler = handleProofCommand, - eventHandler = (state, event) => - state.handleEvent(event).orElse(unhandledEvent(event, state)) - ) - .withTagger(_ => Set(ProofEvent.Tag)) - - def handleProofCommand( - state: State, - command: ProofCommand - ): ProofReplyEffect = - handleCommand(state, command.command) match - case Some(events) => persist(events, command.meta, command.replyTo) - case _ => unhandled(command.command, command.replyTo) - - def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = - state match - case None => - cmd match - case CreateProof( - id, - person, - parameterId, - criterionId, - documents, - Authorized(note) - ) => - Some( - Seq( - ProofCreated(id, person, parameterId, criterionId, documents), - ProofAuthorized(id, note) - ) - ) - case CreateProof( - id, - person, - parameterId, - criterionId, - documents, - _ - ) => - Some( - Seq(ProofCreated(id, person, parameterId, criterionId, documents)) - ) - case _ => None - case Some(proof) if proof.isRevoked => None - case Some(proof) if proof.isAuthorized => - cmd match - case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) - case RevokeProof(id, reason, since, documents) => - Some(Seq((ProofRevoked(id, reason, since, documents)))) - case _ => None - case Some(proof) => - cmd match - case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) - case UpdateProof(id, documents) => - Some(Seq(ProofUpdated(id, documents))) - case RevokeProof(id, reason, since, documents) => - Some(Seq((ProofRevoked(id, reason, since, documents)))) - case _ => None - - private def persist( - events: Seq[Event], - meta: WW, - replyTo: ReplyTo - ): ProofReplyEffect = - Effect - .persist(events.map(ProofEvent(_, meta))) - .thenReply(replyTo)(_ => StatusReply.Ack) - - private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = - Effect.unhandled.thenReply(replyTo)(s => - StatusReply.error(CommandNotAvailable(command, s)) - ) - - private def unhandledEvent(event: ProofEvent, state: State): Nothing = - throw UnhandledEvent(event, state) diff --git a/domain/proof/command/entity/src/ProofCommandBus.scala b/domain/proof/command/entity/src/ProofCommandBus.scala new file mode 100644 index 0000000..3167e02 --- /dev/null +++ b/domain/proof/command/entity/src/ProofCommandBus.scala @@ -0,0 +1,50 @@ +package mdr.pdb +package proof +package command +package entity + +import zio.* +import akka.actor.typed.ActorSystem +import akka.cluster.sharding.typed.scaladsl.EntityContext +import akka.actor.typed.Behavior +import akka.cluster.sharding.typed.scaladsl.ClusterSharding +import akka.util.Timeout +import java.time.Instant + +object ProofCommandBus: + def submitCommand(command: Command): RIO[ProofCommandBus, Unit] = + ZIO.serviceWithZIO(_.submitCommand(command)) + + val layer: RLayer[ActorSystem[_], ProofCommandBus] = + ZIO + .serviceWithZIO[ActorSystem[_]](system => + for + timeout <- Task.attempt( + Timeout.create( + system.settings.config.getDuration("proof-bus.timeout") + ) + ) + // TODO: init only once + _ <- Task.attempt(ProofBehavior.init(system)) + yield ProofCommandBus(system)(using timeout) + ) + .toLayer + +class ProofCommandBus(system: ActorSystem[_])(using timeout: Timeout): + private val sharding = ClusterSharding(system) + + def submitCommand(command: Command): Task[Unit] = + for + entityRef <- Task.attempt( + sharding.entityRefFor(ProofBehavior.EntityKey, command.id) + ) + reply <- ZIO.fromFuture(_ => + entityRef.askWithStatus( + ProofBehavior.ProofCommand( + command, + WhoWhen(OsobniCislo("0123"), Instant.now()), + _ + ) + ) + ) + yield () diff --git a/domain/proof/command/json/src/Codecs.scala b/domain/proof/command/json/src/Codecs.scala deleted file mode 100644 index 6867615..0000000 --- a/domain/proof/command/json/src/Codecs.scala +++ /dev/null @@ -1,13 +0,0 @@ -package mdr.pdb -package proof.command -package json - -import zio.json.* - -trait Codecs extends proof.json.Codecs: - - given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen - given JsonCodec[CreateProof] = DeriveJsonCodec.gen - given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen - given JsonCodec[UpdateProof] = DeriveJsonCodec.gen - given JsonCodec[RevokeProof] = DeriveJsonCodec.gen diff --git a/domain/proof/command/model/src/Command.scala b/domain/proof/command/model/src/Command.scala index 35a15af..1bff3cd 100644 --- a/domain/proof/command/model/src/Command.scala +++ b/domain/proof/command/model/src/Command.scala @@ -5,7 +5,8 @@ import java.time.LocalDate import java.time.Instant -sealed trait Command +sealed trait Command: + def id: Proof.Id sealed trait AuthorizeOption case object Unauthorized extends AuthorizeOption diff --git a/domain/proof/query/api/src/ProofQueryApi.scala b/domain/proof/query/api/src/ProofQueryApi.scala new file mode 100644 index 0000000..19acd07 --- /dev/null +++ b/domain/proof/query/api/src/ProofQueryApi.scala @@ -0,0 +1,16 @@ +package mdr.pdb.proof.query +package api + +import endpoints.Endpoints +import repo.ProofRepository +import fiftyforms.tapir.CustomTapir +import fiftyforms.tapir.InternalServerError + +object ProofQueryApi extends CustomTapir: + + val forUser: ZServerEndpoint[ProofRepository, Any] = + Endpoints.forUser.zServerLogic(osc => + ProofRepository + .matching(ProofRepository.OfPerson(osc)) + .mapError(InternalServerError.fromThrowable) + ) diff --git a/domain/proof/query/endpoints/src/Endpoints.scala b/domain/proof/query/endpoints/src/Endpoints.scala new file mode 100644 index 0000000..e3d9fb9 --- /dev/null +++ b/domain/proof/query/endpoints/src/Endpoints.scala @@ -0,0 +1,21 @@ +package mdr.pdb +package proof +package query.endpoints + +import fiftyforms.tapir.CustomTapir +import mdr.pdb.proof.codecs.Codecs +import fiftyforms.tapir.ServerError +import zio.json.JsonCodec +import zio.json.DeriveJsonCodec + +object Endpoints + extends mdr.pdb.endpoints.Endpoints + with CustomTapir + with Codecs: + + val forUser: Endpoint[Unit, OsobniCislo, ServerError, Seq[Proof], Any] = + endpoint + .in("proof") + .in(path[OsobniCislo]("osobniCislo")) + .out(jsonBody[Seq[Proof]]) + .errorOut(jsonBody[ServerError]) diff --git a/build.sbt b/build.sbt index e6f5e40..227e292 100644 --- a/build.sbt +++ b/build.sbt @@ -14,38 +14,38 @@ lazy val proof = entityProject("proof", file("domain/proof")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .repo(_.dependsOn(`mongo-support`)) .entity(_.dependsOn(`akka-persistence-support`)) .projection(_.dependsOn(`akka-persistence-support`)) - .endpoints(_.dependsOn(`tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val parameters = entityProject("parameters", file("domain/parameters")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) - .endpoints(_.dependsOn(`tapir-support`)) + .codecs(_.dependsOn(codecs, `tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val users = entityProject("users", file("domain/users")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val core = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("core")) -lazy val json = crossProject(JSPlatform, JVMPlatform) +lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) - .in(file("json")) + .in(file("codecs")) .settings(IWDeps.zioJson) - .dependsOn(core) + .dependsOn(core, `tapir-support`) lazy val endpoints = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("endpoints")) - .dependsOn(core, json, `tapir-support`) + .dependsOn(core, codecs, `tapir-support`) // TODO: move all from fiftyforms to iterative works lazy val ui = (project in file("fiftyforms/ui")) @@ -80,7 +80,11 @@ lazy val `akka-persistence-support` = project .in(file("fiftyforms/akka-persistence")) - .settings(IWDeps.akka.profiles.eventsourcedJdbcProjection) + .settings( + IWDeps.akka.libs.persistence, + libraryDependencies += "com.typesafe.akka" %% "akka-cluster-sharding-typed" % IWDeps.akka.V, + IWDeps.akka.profiles.eventsourcedJdbcProjection + ) lazy val app = (project in file("app")) .enablePlugins(ScalaJSPlugin, VitePlugin) @@ -166,6 +170,7 @@ proof.query.api, proof.command.api, proof.query.projection, + proof.command.entity, endpoints.jvm ) diff --git a/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala new file mode 100644 index 0000000..22ebfb8 --- /dev/null +++ b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala @@ -0,0 +1,26 @@ +package mdr.pdb +package codecs + +import zio.json.* +import fiftyforms.tapir.CustomTapir + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + given JsonCodec[WhoWhen] = DeriveJsonCodec.gen + given JsonCodec[OsobniCislo] = + JsonCodec.string.transform(OsobniCislo.apply, _.toString) + given JsonFieldEncoder[OsobniCislo] = + JsonFieldEncoder.string.contramap(_.toString) + given JsonFieldDecoder[OsobniCislo] = + JsonFieldDecoder.string.map(OsobniCislo(_)) + +trait TapirCodecs extends CustomTapir: + given Schema[OsobniCislo] = Schema.string + given Codec.PlainCodec[OsobniCislo] = + Codec.string.mapDecode(OsobniCislo.apply andThen DecodeResult.Value.apply)( + _.toString + ) + given Schema[WhoWhen] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/parameters/shared/codecs/src/Codecs.scala b/domain/parameters/shared/codecs/src/Codecs.scala new file mode 100644 index 0000000..751e79b --- /dev/null +++ b/domain/parameters/shared/codecs/src/Codecs.scala @@ -0,0 +1,10 @@ +package mdr.pdb +package parameters +package codecs + +import zio.json.* + +trait Codecs: + + given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen + given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/parameters/shared/json/src/Codecs.scala b/domain/parameters/shared/json/src/Codecs.scala deleted file mode 100644 index b849072..0000000 --- a/domain/parameters/shared/json/src/Codecs.scala +++ /dev/null @@ -1,10 +0,0 @@ -package mdr.pdb -package parameters -package json - -import zio.json.* - -trait Codecs: - - given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen - given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/proof/command/api/src/ProofCommandApi.scala b/domain/proof/command/api/src/ProofCommandApi.scala new file mode 100644 index 0000000..12f747e --- /dev/null +++ b/domain/proof/command/api/src/ProofCommandApi.scala @@ -0,0 +1,16 @@ +package mdr.pdb.proof.command +package api + +import endpoints.Endpoints +import entity.ProofCommandBus +import fiftyforms.tapir.CustomTapir +import fiftyforms.tapir.InternalServerError + +object ProofCommandApi extends CustomTapir: + + val submitCommand: ZServerEndpoint[ProofCommandBus, Any] = + Endpoints.submitCommand.zServerLogic(cmd => + ProofCommandBus + .submitCommand(cmd) + .mapError(InternalServerError.fromThrowable) + ) diff --git a/domain/proof/command/codecs/src/Codecs.scala b/domain/proof/command/codecs/src/Codecs.scala new file mode 100644 index 0000000..0e75f82 --- /dev/null +++ b/domain/proof/command/codecs/src/Codecs.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof.command +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends proof.codecs.JsonCodecs: + + given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen + given JsonCodec[CreateProof] = DeriveJsonCodec.gen + given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen + given JsonCodec[UpdateProof] = DeriveJsonCodec.gen + given JsonCodec[RevokeProof] = DeriveJsonCodec.gen + given JsonCodec[Command] = DeriveJsonCodec.gen + +trait TapirCodecs extends proof.codecs.TapirCodecs: + + given Schema[AuthorizeOption] = Schema.derived + given Schema[CreateProof] = Schema.derived + given Schema[AuthorizeProof] = Schema.derived + given Schema[UpdateProof] = Schema.derived + given Schema[RevokeProof] = Schema.derived + given Schema[Command] = Schema.derived diff --git a/domain/proof/command/endpoints/src/Endpoints.scala b/domain/proof/command/endpoints/src/Endpoints.scala new file mode 100644 index 0000000..dcd114e --- /dev/null +++ b/domain/proof/command/endpoints/src/Endpoints.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof +package command +package endpoints + +import fiftyforms.tapir.CustomTapir +import mdr.pdb.proof.command.codecs.Codecs +import fiftyforms.tapir.ServerError +import zio.json.JsonCodec +import zio.json.DeriveJsonCodec +import sttp.model.StatusCode + +object Endpoints + extends mdr.pdb.endpoints.Endpoints + with CustomTapir + with Codecs: + + val submitCommand: Endpoint[Unit, Command, ServerError, Unit, Any] = + endpoint + .in("command") + .in("proof") + .post + .in(jsonBody[Command]) + .out(statusCode(StatusCode.Accepted)) + .errorOut(jsonBody[ServerError]) diff --git a/domain/proof/command/entity/src/ProofBehavior.scala b/domain/proof/command/entity/src/ProofBehavior.scala new file mode 100644 index 0000000..f679981 --- /dev/null +++ b/domain/proof/command/entity/src/ProofBehavior.scala @@ -0,0 +1,120 @@ +package mdr.pdb +package proof +package command +package entity + +import akka.persistence.typed.scaladsl.Effect +import akka.persistence.typed.scaladsl.EventSourcedBehavior +import akka.persistence.typed.PersistenceId +import akka.actor.typed.Behavior +import akka.actor.typed.ActorRef +import akka.pattern.StatusReply +import akka.Done + +import fiftyforms.akka.* +import akka.actor.typed.ActorSystem +import akka.cluster.sharding.typed.scaladsl.EntityTypeKey +import akka.cluster.sharding.typed.scaladsl.EntityContext +import akka.cluster.sharding.typed.scaladsl.ClusterSharding +import akka.cluster.sharding.typed.scaladsl.Entity + +object ProofBehavior: + + type ReplyTo = ActorRef[StatusReply[Done]] + + case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) + + type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] + + type ProofReplyEffect = + akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] + + val EntityKey: EntityTypeKey[ProofCommand] = EntityTypeKey("Proof") + + def init(system: ActorSystem[_]): Unit = + val behaviorFactory: EntityContext[ProofCommand] => Behavior[ProofCommand] = + entityContext => ProofBehavior(entityContext.entityId) + ClusterSharding(system).init(Entity(EntityKey)(behaviorFactory)) + + def apply(persistenceId: String): Behavior[ProofCommand] = + import ProofEventHandler.* + EventSourcedBehavior + .withEnforcedReplies[ProofCommand, ProofEvent, State]( + persistenceId = PersistenceId.ofUniqueId(persistenceId), + emptyState = None, + commandHandler = handleProofCommand, + eventHandler = (state, event) => + state.handleEvent(event).orElse(unhandledEvent(event, state)) + ) + .withTagger(_ => Set(ProofEvent.Tag)) + + def handleProofCommand( + state: State, + command: ProofCommand + ): ProofReplyEffect = + handleCommand(state, command.command) match + case Some(events) => persist(events, command.meta, command.replyTo) + case _ => unhandled(command.command, command.replyTo) + + def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = + state match + case None => + cmd match + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + Authorized(note) + ) => + Some( + Seq( + ProofCreated(id, person, parameterId, criterionId, documents), + ProofAuthorized(id, note) + ) + ) + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + _ + ) => + Some( + Seq(ProofCreated(id, person, parameterId, criterionId, documents)) + ) + case _ => None + case Some(proof) if proof.isRevoked => None + case Some(proof) if proof.isAuthorized => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + case Some(proof) => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case UpdateProof(id, documents) => + Some(Seq(ProofUpdated(id, documents))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + + private def persist( + events: Seq[Event], + meta: WW, + replyTo: ReplyTo + ): ProofReplyEffect = + Effect + .persist(events.map(ProofEvent(_, meta))) + .thenReply(replyTo)(_ => StatusReply.Ack) + + private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = + Effect.unhandled.thenReply(replyTo)(s => + StatusReply.error(CommandNotAvailable(command, s)) + ) + + private def unhandledEvent(event: ProofEvent, state: State): Nothing = + throw UnhandledEvent(event, state) diff --git a/domain/proof/command/entity/src/ProofBehaviour.scala b/domain/proof/command/entity/src/ProofBehaviour.scala deleted file mode 100644 index e8c7e4e..0000000 --- a/domain/proof/command/entity/src/ProofBehaviour.scala +++ /dev/null @@ -1,108 +0,0 @@ -package mdr.pdb -package proof -package command -package entity - -import akka.persistence.typed.scaladsl.EventSourcedBehavior -import akka.persistence.typed.PersistenceId -import akka.actor.typed.Behavior -import akka.actor.typed.ActorRef -import akka.pattern.StatusReply -import akka.Done - -import fiftyforms.akka.* -import akka.persistence.typed.scaladsl.Effect - -object ProofBehaviour: - - type ReplyTo = ActorRef[StatusReply[Done]] - - case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) - - type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] - - type ProofReplyEffect = - akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] - - def apply(persistenceId: PersistenceId): Behavior[ProofCommand] = - import ProofEventHandler.* - EventSourcedBehavior - .withEnforcedReplies[ProofCommand, ProofEvent, State]( - persistenceId = persistenceId, - emptyState = None, - commandHandler = handleProofCommand, - eventHandler = (state, event) => - state.handleEvent(event).orElse(unhandledEvent(event, state)) - ) - .withTagger(_ => Set(ProofEvent.Tag)) - - def handleProofCommand( - state: State, - command: ProofCommand - ): ProofReplyEffect = - handleCommand(state, command.command) match - case Some(events) => persist(events, command.meta, command.replyTo) - case _ => unhandled(command.command, command.replyTo) - - def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = - state match - case None => - cmd match - case CreateProof( - id, - person, - parameterId, - criterionId, - documents, - Authorized(note) - ) => - Some( - Seq( - ProofCreated(id, person, parameterId, criterionId, documents), - ProofAuthorized(id, note) - ) - ) - case CreateProof( - id, - person, - parameterId, - criterionId, - documents, - _ - ) => - Some( - Seq(ProofCreated(id, person, parameterId, criterionId, documents)) - ) - case _ => None - case Some(proof) if proof.isRevoked => None - case Some(proof) if proof.isAuthorized => - cmd match - case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) - case RevokeProof(id, reason, since, documents) => - Some(Seq((ProofRevoked(id, reason, since, documents)))) - case _ => None - case Some(proof) => - cmd match - case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) - case UpdateProof(id, documents) => - Some(Seq(ProofUpdated(id, documents))) - case RevokeProof(id, reason, since, documents) => - Some(Seq((ProofRevoked(id, reason, since, documents)))) - case _ => None - - private def persist( - events: Seq[Event], - meta: WW, - replyTo: ReplyTo - ): ProofReplyEffect = - Effect - .persist(events.map(ProofEvent(_, meta))) - .thenReply(replyTo)(_ => StatusReply.Ack) - - private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = - Effect.unhandled.thenReply(replyTo)(s => - StatusReply.error(CommandNotAvailable(command, s)) - ) - - private def unhandledEvent(event: ProofEvent, state: State): Nothing = - throw UnhandledEvent(event, state) diff --git a/domain/proof/command/entity/src/ProofCommandBus.scala b/domain/proof/command/entity/src/ProofCommandBus.scala new file mode 100644 index 0000000..3167e02 --- /dev/null +++ b/domain/proof/command/entity/src/ProofCommandBus.scala @@ -0,0 +1,50 @@ +package mdr.pdb +package proof +package command +package entity + +import zio.* +import akka.actor.typed.ActorSystem +import akka.cluster.sharding.typed.scaladsl.EntityContext +import akka.actor.typed.Behavior +import akka.cluster.sharding.typed.scaladsl.ClusterSharding +import akka.util.Timeout +import java.time.Instant + +object ProofCommandBus: + def submitCommand(command: Command): RIO[ProofCommandBus, Unit] = + ZIO.serviceWithZIO(_.submitCommand(command)) + + val layer: RLayer[ActorSystem[_], ProofCommandBus] = + ZIO + .serviceWithZIO[ActorSystem[_]](system => + for + timeout <- Task.attempt( + Timeout.create( + system.settings.config.getDuration("proof-bus.timeout") + ) + ) + // TODO: init only once + _ <- Task.attempt(ProofBehavior.init(system)) + yield ProofCommandBus(system)(using timeout) + ) + .toLayer + +class ProofCommandBus(system: ActorSystem[_])(using timeout: Timeout): + private val sharding = ClusterSharding(system) + + def submitCommand(command: Command): Task[Unit] = + for + entityRef <- Task.attempt( + sharding.entityRefFor(ProofBehavior.EntityKey, command.id) + ) + reply <- ZIO.fromFuture(_ => + entityRef.askWithStatus( + ProofBehavior.ProofCommand( + command, + WhoWhen(OsobniCislo("0123"), Instant.now()), + _ + ) + ) + ) + yield () diff --git a/domain/proof/command/json/src/Codecs.scala b/domain/proof/command/json/src/Codecs.scala deleted file mode 100644 index 6867615..0000000 --- a/domain/proof/command/json/src/Codecs.scala +++ /dev/null @@ -1,13 +0,0 @@ -package mdr.pdb -package proof.command -package json - -import zio.json.* - -trait Codecs extends proof.json.Codecs: - - given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen - given JsonCodec[CreateProof] = DeriveJsonCodec.gen - given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen - given JsonCodec[UpdateProof] = DeriveJsonCodec.gen - given JsonCodec[RevokeProof] = DeriveJsonCodec.gen diff --git a/domain/proof/command/model/src/Command.scala b/domain/proof/command/model/src/Command.scala index 35a15af..1bff3cd 100644 --- a/domain/proof/command/model/src/Command.scala +++ b/domain/proof/command/model/src/Command.scala @@ -5,7 +5,8 @@ import java.time.LocalDate import java.time.Instant -sealed trait Command +sealed trait Command: + def id: Proof.Id sealed trait AuthorizeOption case object Unauthorized extends AuthorizeOption diff --git a/domain/proof/query/api/src/ProofQueryApi.scala b/domain/proof/query/api/src/ProofQueryApi.scala new file mode 100644 index 0000000..19acd07 --- /dev/null +++ b/domain/proof/query/api/src/ProofQueryApi.scala @@ -0,0 +1,16 @@ +package mdr.pdb.proof.query +package api + +import endpoints.Endpoints +import repo.ProofRepository +import fiftyforms.tapir.CustomTapir +import fiftyforms.tapir.InternalServerError + +object ProofQueryApi extends CustomTapir: + + val forUser: ZServerEndpoint[ProofRepository, Any] = + Endpoints.forUser.zServerLogic(osc => + ProofRepository + .matching(ProofRepository.OfPerson(osc)) + .mapError(InternalServerError.fromThrowable) + ) diff --git a/domain/proof/query/endpoints/src/Endpoints.scala b/domain/proof/query/endpoints/src/Endpoints.scala new file mode 100644 index 0000000..e3d9fb9 --- /dev/null +++ b/domain/proof/query/endpoints/src/Endpoints.scala @@ -0,0 +1,21 @@ +package mdr.pdb +package proof +package query.endpoints + +import fiftyforms.tapir.CustomTapir +import mdr.pdb.proof.codecs.Codecs +import fiftyforms.tapir.ServerError +import zio.json.JsonCodec +import zio.json.DeriveJsonCodec + +object Endpoints + extends mdr.pdb.endpoints.Endpoints + with CustomTapir + with Codecs: + + val forUser: Endpoint[Unit, OsobniCislo, ServerError, Seq[Proof], Any] = + endpoint + .in("proof") + .in(path[OsobniCislo]("osobniCislo")) + .out(jsonBody[Seq[Proof]]) + .errorOut(jsonBody[ServerError]) diff --git a/domain/proof/query/repo/src/MongoProofRepository.scala b/domain/proof/query/repo/src/MongoProofRepository.scala index fad055c..cf19e7a 100644 --- a/domain/proof/query/repo/src/MongoProofRepository.scala +++ b/domain/proof/query/repo/src/MongoProofRepository.scala @@ -9,6 +9,7 @@ import org.bson.json.JsonObject import fiftyforms.mongo.MongoJsonRepository +// TODO: extract common mongo repo config, just nest under mongo / case class MongoProofConfig(db: String, collection: String) object MongoProofConfig { @@ -47,7 +48,7 @@ collection: MongoCollection[JsonObject] ) extends ProofRepositoryWrite: import ProofRepository.* - import mdr.pdb.proof.json.Codecs.given + import mdr.pdb.proof.codecs.Codecs.given private val jsonRepo = MongoJsonRepository[Proof, String, Criteria]( collection, { diff --git a/build.sbt b/build.sbt index e6f5e40..227e292 100644 --- a/build.sbt +++ b/build.sbt @@ -14,38 +14,38 @@ lazy val proof = entityProject("proof", file("domain/proof")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .repo(_.dependsOn(`mongo-support`)) .entity(_.dependsOn(`akka-persistence-support`)) .projection(_.dependsOn(`akka-persistence-support`)) - .endpoints(_.dependsOn(`tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val parameters = entityProject("parameters", file("domain/parameters")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) - .endpoints(_.dependsOn(`tapir-support`)) + .codecs(_.dependsOn(codecs, `tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val users = entityProject("users", file("domain/users")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val core = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("core")) -lazy val json = crossProject(JSPlatform, JVMPlatform) +lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) - .in(file("json")) + .in(file("codecs")) .settings(IWDeps.zioJson) - .dependsOn(core) + .dependsOn(core, `tapir-support`) lazy val endpoints = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("endpoints")) - .dependsOn(core, json, `tapir-support`) + .dependsOn(core, codecs, `tapir-support`) // TODO: move all from fiftyforms to iterative works lazy val ui = (project in file("fiftyforms/ui")) @@ -80,7 +80,11 @@ lazy val `akka-persistence-support` = project .in(file("fiftyforms/akka-persistence")) - .settings(IWDeps.akka.profiles.eventsourcedJdbcProjection) + .settings( + IWDeps.akka.libs.persistence, + libraryDependencies += "com.typesafe.akka" %% "akka-cluster-sharding-typed" % IWDeps.akka.V, + IWDeps.akka.profiles.eventsourcedJdbcProjection + ) lazy val app = (project in file("app")) .enablePlugins(ScalaJSPlugin, VitePlugin) @@ -166,6 +170,7 @@ proof.query.api, proof.command.api, proof.query.projection, + proof.command.entity, endpoints.jvm ) diff --git a/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala new file mode 100644 index 0000000..22ebfb8 --- /dev/null +++ b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala @@ -0,0 +1,26 @@ +package mdr.pdb +package codecs + +import zio.json.* +import fiftyforms.tapir.CustomTapir + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + given JsonCodec[WhoWhen] = DeriveJsonCodec.gen + given JsonCodec[OsobniCislo] = + JsonCodec.string.transform(OsobniCislo.apply, _.toString) + given JsonFieldEncoder[OsobniCislo] = + JsonFieldEncoder.string.contramap(_.toString) + given JsonFieldDecoder[OsobniCislo] = + JsonFieldDecoder.string.map(OsobniCislo(_)) + +trait TapirCodecs extends CustomTapir: + given Schema[OsobniCislo] = Schema.string + given Codec.PlainCodec[OsobniCislo] = + Codec.string.mapDecode(OsobniCislo.apply andThen DecodeResult.Value.apply)( + _.toString + ) + given Schema[WhoWhen] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/parameters/shared/codecs/src/Codecs.scala b/domain/parameters/shared/codecs/src/Codecs.scala new file mode 100644 index 0000000..751e79b --- /dev/null +++ b/domain/parameters/shared/codecs/src/Codecs.scala @@ -0,0 +1,10 @@ +package mdr.pdb +package parameters +package codecs + +import zio.json.* + +trait Codecs: + + given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen + given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/parameters/shared/json/src/Codecs.scala b/domain/parameters/shared/json/src/Codecs.scala deleted file mode 100644 index b849072..0000000 --- a/domain/parameters/shared/json/src/Codecs.scala +++ /dev/null @@ -1,10 +0,0 @@ -package mdr.pdb -package parameters -package json - -import zio.json.* - -trait Codecs: - - given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen - given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/proof/command/api/src/ProofCommandApi.scala b/domain/proof/command/api/src/ProofCommandApi.scala new file mode 100644 index 0000000..12f747e --- /dev/null +++ b/domain/proof/command/api/src/ProofCommandApi.scala @@ -0,0 +1,16 @@ +package mdr.pdb.proof.command +package api + +import endpoints.Endpoints +import entity.ProofCommandBus +import fiftyforms.tapir.CustomTapir +import fiftyforms.tapir.InternalServerError + +object ProofCommandApi extends CustomTapir: + + val submitCommand: ZServerEndpoint[ProofCommandBus, Any] = + Endpoints.submitCommand.zServerLogic(cmd => + ProofCommandBus + .submitCommand(cmd) + .mapError(InternalServerError.fromThrowable) + ) diff --git a/domain/proof/command/codecs/src/Codecs.scala b/domain/proof/command/codecs/src/Codecs.scala new file mode 100644 index 0000000..0e75f82 --- /dev/null +++ b/domain/proof/command/codecs/src/Codecs.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof.command +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends proof.codecs.JsonCodecs: + + given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen + given JsonCodec[CreateProof] = DeriveJsonCodec.gen + given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen + given JsonCodec[UpdateProof] = DeriveJsonCodec.gen + given JsonCodec[RevokeProof] = DeriveJsonCodec.gen + given JsonCodec[Command] = DeriveJsonCodec.gen + +trait TapirCodecs extends proof.codecs.TapirCodecs: + + given Schema[AuthorizeOption] = Schema.derived + given Schema[CreateProof] = Schema.derived + given Schema[AuthorizeProof] = Schema.derived + given Schema[UpdateProof] = Schema.derived + given Schema[RevokeProof] = Schema.derived + given Schema[Command] = Schema.derived diff --git a/domain/proof/command/endpoints/src/Endpoints.scala b/domain/proof/command/endpoints/src/Endpoints.scala new file mode 100644 index 0000000..dcd114e --- /dev/null +++ b/domain/proof/command/endpoints/src/Endpoints.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof +package command +package endpoints + +import fiftyforms.tapir.CustomTapir +import mdr.pdb.proof.command.codecs.Codecs +import fiftyforms.tapir.ServerError +import zio.json.JsonCodec +import zio.json.DeriveJsonCodec +import sttp.model.StatusCode + +object Endpoints + extends mdr.pdb.endpoints.Endpoints + with CustomTapir + with Codecs: + + val submitCommand: Endpoint[Unit, Command, ServerError, Unit, Any] = + endpoint + .in("command") + .in("proof") + .post + .in(jsonBody[Command]) + .out(statusCode(StatusCode.Accepted)) + .errorOut(jsonBody[ServerError]) diff --git a/domain/proof/command/entity/src/ProofBehavior.scala b/domain/proof/command/entity/src/ProofBehavior.scala new file mode 100644 index 0000000..f679981 --- /dev/null +++ b/domain/proof/command/entity/src/ProofBehavior.scala @@ -0,0 +1,120 @@ +package mdr.pdb +package proof +package command +package entity + +import akka.persistence.typed.scaladsl.Effect +import akka.persistence.typed.scaladsl.EventSourcedBehavior +import akka.persistence.typed.PersistenceId +import akka.actor.typed.Behavior +import akka.actor.typed.ActorRef +import akka.pattern.StatusReply +import akka.Done + +import fiftyforms.akka.* +import akka.actor.typed.ActorSystem +import akka.cluster.sharding.typed.scaladsl.EntityTypeKey +import akka.cluster.sharding.typed.scaladsl.EntityContext +import akka.cluster.sharding.typed.scaladsl.ClusterSharding +import akka.cluster.sharding.typed.scaladsl.Entity + +object ProofBehavior: + + type ReplyTo = ActorRef[StatusReply[Done]] + + case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) + + type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] + + type ProofReplyEffect = + akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] + + val EntityKey: EntityTypeKey[ProofCommand] = EntityTypeKey("Proof") + + def init(system: ActorSystem[_]): Unit = + val behaviorFactory: EntityContext[ProofCommand] => Behavior[ProofCommand] = + entityContext => ProofBehavior(entityContext.entityId) + ClusterSharding(system).init(Entity(EntityKey)(behaviorFactory)) + + def apply(persistenceId: String): Behavior[ProofCommand] = + import ProofEventHandler.* + EventSourcedBehavior + .withEnforcedReplies[ProofCommand, ProofEvent, State]( + persistenceId = PersistenceId.ofUniqueId(persistenceId), + emptyState = None, + commandHandler = handleProofCommand, + eventHandler = (state, event) => + state.handleEvent(event).orElse(unhandledEvent(event, state)) + ) + .withTagger(_ => Set(ProofEvent.Tag)) + + def handleProofCommand( + state: State, + command: ProofCommand + ): ProofReplyEffect = + handleCommand(state, command.command) match + case Some(events) => persist(events, command.meta, command.replyTo) + case _ => unhandled(command.command, command.replyTo) + + def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = + state match + case None => + cmd match + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + Authorized(note) + ) => + Some( + Seq( + ProofCreated(id, person, parameterId, criterionId, documents), + ProofAuthorized(id, note) + ) + ) + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + _ + ) => + Some( + Seq(ProofCreated(id, person, parameterId, criterionId, documents)) + ) + case _ => None + case Some(proof) if proof.isRevoked => None + case Some(proof) if proof.isAuthorized => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + case Some(proof) => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case UpdateProof(id, documents) => + Some(Seq(ProofUpdated(id, documents))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + + private def persist( + events: Seq[Event], + meta: WW, + replyTo: ReplyTo + ): ProofReplyEffect = + Effect + .persist(events.map(ProofEvent(_, meta))) + .thenReply(replyTo)(_ => StatusReply.Ack) + + private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = + Effect.unhandled.thenReply(replyTo)(s => + StatusReply.error(CommandNotAvailable(command, s)) + ) + + private def unhandledEvent(event: ProofEvent, state: State): Nothing = + throw UnhandledEvent(event, state) diff --git a/domain/proof/command/entity/src/ProofBehaviour.scala b/domain/proof/command/entity/src/ProofBehaviour.scala deleted file mode 100644 index e8c7e4e..0000000 --- a/domain/proof/command/entity/src/ProofBehaviour.scala +++ /dev/null @@ -1,108 +0,0 @@ -package mdr.pdb -package proof -package command -package entity - -import akka.persistence.typed.scaladsl.EventSourcedBehavior -import akka.persistence.typed.PersistenceId -import akka.actor.typed.Behavior -import akka.actor.typed.ActorRef -import akka.pattern.StatusReply -import akka.Done - -import fiftyforms.akka.* -import akka.persistence.typed.scaladsl.Effect - -object ProofBehaviour: - - type ReplyTo = ActorRef[StatusReply[Done]] - - case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) - - type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] - - type ProofReplyEffect = - akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] - - def apply(persistenceId: PersistenceId): Behavior[ProofCommand] = - import ProofEventHandler.* - EventSourcedBehavior - .withEnforcedReplies[ProofCommand, ProofEvent, State]( - persistenceId = persistenceId, - emptyState = None, - commandHandler = handleProofCommand, - eventHandler = (state, event) => - state.handleEvent(event).orElse(unhandledEvent(event, state)) - ) - .withTagger(_ => Set(ProofEvent.Tag)) - - def handleProofCommand( - state: State, - command: ProofCommand - ): ProofReplyEffect = - handleCommand(state, command.command) match - case Some(events) => persist(events, command.meta, command.replyTo) - case _ => unhandled(command.command, command.replyTo) - - def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = - state match - case None => - cmd match - case CreateProof( - id, - person, - parameterId, - criterionId, - documents, - Authorized(note) - ) => - Some( - Seq( - ProofCreated(id, person, parameterId, criterionId, documents), - ProofAuthorized(id, note) - ) - ) - case CreateProof( - id, - person, - parameterId, - criterionId, - documents, - _ - ) => - Some( - Seq(ProofCreated(id, person, parameterId, criterionId, documents)) - ) - case _ => None - case Some(proof) if proof.isRevoked => None - case Some(proof) if proof.isAuthorized => - cmd match - case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) - case RevokeProof(id, reason, since, documents) => - Some(Seq((ProofRevoked(id, reason, since, documents)))) - case _ => None - case Some(proof) => - cmd match - case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) - case UpdateProof(id, documents) => - Some(Seq(ProofUpdated(id, documents))) - case RevokeProof(id, reason, since, documents) => - Some(Seq((ProofRevoked(id, reason, since, documents)))) - case _ => None - - private def persist( - events: Seq[Event], - meta: WW, - replyTo: ReplyTo - ): ProofReplyEffect = - Effect - .persist(events.map(ProofEvent(_, meta))) - .thenReply(replyTo)(_ => StatusReply.Ack) - - private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = - Effect.unhandled.thenReply(replyTo)(s => - StatusReply.error(CommandNotAvailable(command, s)) - ) - - private def unhandledEvent(event: ProofEvent, state: State): Nothing = - throw UnhandledEvent(event, state) diff --git a/domain/proof/command/entity/src/ProofCommandBus.scala b/domain/proof/command/entity/src/ProofCommandBus.scala new file mode 100644 index 0000000..3167e02 --- /dev/null +++ b/domain/proof/command/entity/src/ProofCommandBus.scala @@ -0,0 +1,50 @@ +package mdr.pdb +package proof +package command +package entity + +import zio.* +import akka.actor.typed.ActorSystem +import akka.cluster.sharding.typed.scaladsl.EntityContext +import akka.actor.typed.Behavior +import akka.cluster.sharding.typed.scaladsl.ClusterSharding +import akka.util.Timeout +import java.time.Instant + +object ProofCommandBus: + def submitCommand(command: Command): RIO[ProofCommandBus, Unit] = + ZIO.serviceWithZIO(_.submitCommand(command)) + + val layer: RLayer[ActorSystem[_], ProofCommandBus] = + ZIO + .serviceWithZIO[ActorSystem[_]](system => + for + timeout <- Task.attempt( + Timeout.create( + system.settings.config.getDuration("proof-bus.timeout") + ) + ) + // TODO: init only once + _ <- Task.attempt(ProofBehavior.init(system)) + yield ProofCommandBus(system)(using timeout) + ) + .toLayer + +class ProofCommandBus(system: ActorSystem[_])(using timeout: Timeout): + private val sharding = ClusterSharding(system) + + def submitCommand(command: Command): Task[Unit] = + for + entityRef <- Task.attempt( + sharding.entityRefFor(ProofBehavior.EntityKey, command.id) + ) + reply <- ZIO.fromFuture(_ => + entityRef.askWithStatus( + ProofBehavior.ProofCommand( + command, + WhoWhen(OsobniCislo("0123"), Instant.now()), + _ + ) + ) + ) + yield () diff --git a/domain/proof/command/json/src/Codecs.scala b/domain/proof/command/json/src/Codecs.scala deleted file mode 100644 index 6867615..0000000 --- a/domain/proof/command/json/src/Codecs.scala +++ /dev/null @@ -1,13 +0,0 @@ -package mdr.pdb -package proof.command -package json - -import zio.json.* - -trait Codecs extends proof.json.Codecs: - - given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen - given JsonCodec[CreateProof] = DeriveJsonCodec.gen - given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen - given JsonCodec[UpdateProof] = DeriveJsonCodec.gen - given JsonCodec[RevokeProof] = DeriveJsonCodec.gen diff --git a/domain/proof/command/model/src/Command.scala b/domain/proof/command/model/src/Command.scala index 35a15af..1bff3cd 100644 --- a/domain/proof/command/model/src/Command.scala +++ b/domain/proof/command/model/src/Command.scala @@ -5,7 +5,8 @@ import java.time.LocalDate import java.time.Instant -sealed trait Command +sealed trait Command: + def id: Proof.Id sealed trait AuthorizeOption case object Unauthorized extends AuthorizeOption diff --git a/domain/proof/query/api/src/ProofQueryApi.scala b/domain/proof/query/api/src/ProofQueryApi.scala new file mode 100644 index 0000000..19acd07 --- /dev/null +++ b/domain/proof/query/api/src/ProofQueryApi.scala @@ -0,0 +1,16 @@ +package mdr.pdb.proof.query +package api + +import endpoints.Endpoints +import repo.ProofRepository +import fiftyforms.tapir.CustomTapir +import fiftyforms.tapir.InternalServerError + +object ProofQueryApi extends CustomTapir: + + val forUser: ZServerEndpoint[ProofRepository, Any] = + Endpoints.forUser.zServerLogic(osc => + ProofRepository + .matching(ProofRepository.OfPerson(osc)) + .mapError(InternalServerError.fromThrowable) + ) diff --git a/domain/proof/query/endpoints/src/Endpoints.scala b/domain/proof/query/endpoints/src/Endpoints.scala new file mode 100644 index 0000000..e3d9fb9 --- /dev/null +++ b/domain/proof/query/endpoints/src/Endpoints.scala @@ -0,0 +1,21 @@ +package mdr.pdb +package proof +package query.endpoints + +import fiftyforms.tapir.CustomTapir +import mdr.pdb.proof.codecs.Codecs +import fiftyforms.tapir.ServerError +import zio.json.JsonCodec +import zio.json.DeriveJsonCodec + +object Endpoints + extends mdr.pdb.endpoints.Endpoints + with CustomTapir + with Codecs: + + val forUser: Endpoint[Unit, OsobniCislo, ServerError, Seq[Proof], Any] = + endpoint + .in("proof") + .in(path[OsobniCislo]("osobniCislo")) + .out(jsonBody[Seq[Proof]]) + .errorOut(jsonBody[ServerError]) diff --git a/domain/proof/query/repo/src/MongoProofRepository.scala b/domain/proof/query/repo/src/MongoProofRepository.scala index fad055c..cf19e7a 100644 --- a/domain/proof/query/repo/src/MongoProofRepository.scala +++ b/domain/proof/query/repo/src/MongoProofRepository.scala @@ -9,6 +9,7 @@ import org.bson.json.JsonObject import fiftyforms.mongo.MongoJsonRepository +// TODO: extract common mongo repo config, just nest under mongo / case class MongoProofConfig(db: String, collection: String) object MongoProofConfig { @@ -47,7 +48,7 @@ collection: MongoCollection[JsonObject] ) extends ProofRepositoryWrite: import ProofRepository.* - import mdr.pdb.proof.json.Codecs.given + import mdr.pdb.proof.codecs.Codecs.given private val jsonRepo = MongoJsonRepository[Proof, String, Criteria]( collection, { diff --git a/domain/proof/shared/codecs/src/Codecs.scala b/domain/proof/shared/codecs/src/Codecs.scala new file mode 100644 index 0000000..068f37b --- /dev/null +++ b/domain/proof/shared/codecs/src/Codecs.scala @@ -0,0 +1,20 @@ +package mdr.pdb.proof +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends mdr.pdb.codecs.JsonCodecs: + given JsonCodec[Authorization] = DeriveJsonCodec.gen + given JsonCodec[RevocationReason] = DeriveJsonCodec.gen + given JsonCodec[Revocation] = DeriveJsonCodec.gen + given JsonCodec[Proof] = DeriveJsonCodec.gen + +trait TapirCodecs extends mdr.pdb.codecs.TapirCodecs: + given Schema[Authorization] = Schema.derived + given Schema[RevocationReason] = Schema.derived + given Schema[Revocation] = Schema.derived + given Schema[Proof] = Schema.derived + +object Codecs extends Codecs diff --git a/build.sbt b/build.sbt index e6f5e40..227e292 100644 --- a/build.sbt +++ b/build.sbt @@ -14,38 +14,38 @@ lazy val proof = entityProject("proof", file("domain/proof")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .repo(_.dependsOn(`mongo-support`)) .entity(_.dependsOn(`akka-persistence-support`)) .projection(_.dependsOn(`akka-persistence-support`)) - .endpoints(_.dependsOn(`tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val parameters = entityProject("parameters", file("domain/parameters")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) - .endpoints(_.dependsOn(`tapir-support`)) + .codecs(_.dependsOn(codecs, `tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val users = entityProject("users", file("domain/users")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val core = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("core")) -lazy val json = crossProject(JSPlatform, JVMPlatform) +lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) - .in(file("json")) + .in(file("codecs")) .settings(IWDeps.zioJson) - .dependsOn(core) + .dependsOn(core, `tapir-support`) lazy val endpoints = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("endpoints")) - .dependsOn(core, json, `tapir-support`) + .dependsOn(core, codecs, `tapir-support`) // TODO: move all from fiftyforms to iterative works lazy val ui = (project in file("fiftyforms/ui")) @@ -80,7 +80,11 @@ lazy val `akka-persistence-support` = project .in(file("fiftyforms/akka-persistence")) - .settings(IWDeps.akka.profiles.eventsourcedJdbcProjection) + .settings( + IWDeps.akka.libs.persistence, + libraryDependencies += "com.typesafe.akka" %% "akka-cluster-sharding-typed" % IWDeps.akka.V, + IWDeps.akka.profiles.eventsourcedJdbcProjection + ) lazy val app = (project in file("app")) .enablePlugins(ScalaJSPlugin, VitePlugin) @@ -166,6 +170,7 @@ proof.query.api, proof.command.api, proof.query.projection, + proof.command.entity, endpoints.jvm ) diff --git a/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala new file mode 100644 index 0000000..22ebfb8 --- /dev/null +++ b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala @@ -0,0 +1,26 @@ +package mdr.pdb +package codecs + +import zio.json.* +import fiftyforms.tapir.CustomTapir + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + given JsonCodec[WhoWhen] = DeriveJsonCodec.gen + given JsonCodec[OsobniCislo] = + JsonCodec.string.transform(OsobniCislo.apply, _.toString) + given JsonFieldEncoder[OsobniCislo] = + JsonFieldEncoder.string.contramap(_.toString) + given JsonFieldDecoder[OsobniCislo] = + JsonFieldDecoder.string.map(OsobniCislo(_)) + +trait TapirCodecs extends CustomTapir: + given Schema[OsobniCislo] = Schema.string + given Codec.PlainCodec[OsobniCislo] = + Codec.string.mapDecode(OsobniCislo.apply andThen DecodeResult.Value.apply)( + _.toString + ) + given Schema[WhoWhen] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/parameters/shared/codecs/src/Codecs.scala b/domain/parameters/shared/codecs/src/Codecs.scala new file mode 100644 index 0000000..751e79b --- /dev/null +++ b/domain/parameters/shared/codecs/src/Codecs.scala @@ -0,0 +1,10 @@ +package mdr.pdb +package parameters +package codecs + +import zio.json.* + +trait Codecs: + + given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen + given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/parameters/shared/json/src/Codecs.scala b/domain/parameters/shared/json/src/Codecs.scala deleted file mode 100644 index b849072..0000000 --- a/domain/parameters/shared/json/src/Codecs.scala +++ /dev/null @@ -1,10 +0,0 @@ -package mdr.pdb -package parameters -package json - -import zio.json.* - -trait Codecs: - - given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen - given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/proof/command/api/src/ProofCommandApi.scala b/domain/proof/command/api/src/ProofCommandApi.scala new file mode 100644 index 0000000..12f747e --- /dev/null +++ b/domain/proof/command/api/src/ProofCommandApi.scala @@ -0,0 +1,16 @@ +package mdr.pdb.proof.command +package api + +import endpoints.Endpoints +import entity.ProofCommandBus +import fiftyforms.tapir.CustomTapir +import fiftyforms.tapir.InternalServerError + +object ProofCommandApi extends CustomTapir: + + val submitCommand: ZServerEndpoint[ProofCommandBus, Any] = + Endpoints.submitCommand.zServerLogic(cmd => + ProofCommandBus + .submitCommand(cmd) + .mapError(InternalServerError.fromThrowable) + ) diff --git a/domain/proof/command/codecs/src/Codecs.scala b/domain/proof/command/codecs/src/Codecs.scala new file mode 100644 index 0000000..0e75f82 --- /dev/null +++ b/domain/proof/command/codecs/src/Codecs.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof.command +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends proof.codecs.JsonCodecs: + + given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen + given JsonCodec[CreateProof] = DeriveJsonCodec.gen + given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen + given JsonCodec[UpdateProof] = DeriveJsonCodec.gen + given JsonCodec[RevokeProof] = DeriveJsonCodec.gen + given JsonCodec[Command] = DeriveJsonCodec.gen + +trait TapirCodecs extends proof.codecs.TapirCodecs: + + given Schema[AuthorizeOption] = Schema.derived + given Schema[CreateProof] = Schema.derived + given Schema[AuthorizeProof] = Schema.derived + given Schema[UpdateProof] = Schema.derived + given Schema[RevokeProof] = Schema.derived + given Schema[Command] = Schema.derived diff --git a/domain/proof/command/endpoints/src/Endpoints.scala b/domain/proof/command/endpoints/src/Endpoints.scala new file mode 100644 index 0000000..dcd114e --- /dev/null +++ b/domain/proof/command/endpoints/src/Endpoints.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof +package command +package endpoints + +import fiftyforms.tapir.CustomTapir +import mdr.pdb.proof.command.codecs.Codecs +import fiftyforms.tapir.ServerError +import zio.json.JsonCodec +import zio.json.DeriveJsonCodec +import sttp.model.StatusCode + +object Endpoints + extends mdr.pdb.endpoints.Endpoints + with CustomTapir + with Codecs: + + val submitCommand: Endpoint[Unit, Command, ServerError, Unit, Any] = + endpoint + .in("command") + .in("proof") + .post + .in(jsonBody[Command]) + .out(statusCode(StatusCode.Accepted)) + .errorOut(jsonBody[ServerError]) diff --git a/domain/proof/command/entity/src/ProofBehavior.scala b/domain/proof/command/entity/src/ProofBehavior.scala new file mode 100644 index 0000000..f679981 --- /dev/null +++ b/domain/proof/command/entity/src/ProofBehavior.scala @@ -0,0 +1,120 @@ +package mdr.pdb +package proof +package command +package entity + +import akka.persistence.typed.scaladsl.Effect +import akka.persistence.typed.scaladsl.EventSourcedBehavior +import akka.persistence.typed.PersistenceId +import akka.actor.typed.Behavior +import akka.actor.typed.ActorRef +import akka.pattern.StatusReply +import akka.Done + +import fiftyforms.akka.* +import akka.actor.typed.ActorSystem +import akka.cluster.sharding.typed.scaladsl.EntityTypeKey +import akka.cluster.sharding.typed.scaladsl.EntityContext +import akka.cluster.sharding.typed.scaladsl.ClusterSharding +import akka.cluster.sharding.typed.scaladsl.Entity + +object ProofBehavior: + + type ReplyTo = ActorRef[StatusReply[Done]] + + case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) + + type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] + + type ProofReplyEffect = + akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] + + val EntityKey: EntityTypeKey[ProofCommand] = EntityTypeKey("Proof") + + def init(system: ActorSystem[_]): Unit = + val behaviorFactory: EntityContext[ProofCommand] => Behavior[ProofCommand] = + entityContext => ProofBehavior(entityContext.entityId) + ClusterSharding(system).init(Entity(EntityKey)(behaviorFactory)) + + def apply(persistenceId: String): Behavior[ProofCommand] = + import ProofEventHandler.* + EventSourcedBehavior + .withEnforcedReplies[ProofCommand, ProofEvent, State]( + persistenceId = PersistenceId.ofUniqueId(persistenceId), + emptyState = None, + commandHandler = handleProofCommand, + eventHandler = (state, event) => + state.handleEvent(event).orElse(unhandledEvent(event, state)) + ) + .withTagger(_ => Set(ProofEvent.Tag)) + + def handleProofCommand( + state: State, + command: ProofCommand + ): ProofReplyEffect = + handleCommand(state, command.command) match + case Some(events) => persist(events, command.meta, command.replyTo) + case _ => unhandled(command.command, command.replyTo) + + def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = + state match + case None => + cmd match + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + Authorized(note) + ) => + Some( + Seq( + ProofCreated(id, person, parameterId, criterionId, documents), + ProofAuthorized(id, note) + ) + ) + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + _ + ) => + Some( + Seq(ProofCreated(id, person, parameterId, criterionId, documents)) + ) + case _ => None + case Some(proof) if proof.isRevoked => None + case Some(proof) if proof.isAuthorized => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + case Some(proof) => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case UpdateProof(id, documents) => + Some(Seq(ProofUpdated(id, documents))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + + private def persist( + events: Seq[Event], + meta: WW, + replyTo: ReplyTo + ): ProofReplyEffect = + Effect + .persist(events.map(ProofEvent(_, meta))) + .thenReply(replyTo)(_ => StatusReply.Ack) + + private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = + Effect.unhandled.thenReply(replyTo)(s => + StatusReply.error(CommandNotAvailable(command, s)) + ) + + private def unhandledEvent(event: ProofEvent, state: State): Nothing = + throw UnhandledEvent(event, state) diff --git a/domain/proof/command/entity/src/ProofBehaviour.scala b/domain/proof/command/entity/src/ProofBehaviour.scala deleted file mode 100644 index e8c7e4e..0000000 --- a/domain/proof/command/entity/src/ProofBehaviour.scala +++ /dev/null @@ -1,108 +0,0 @@ -package mdr.pdb -package proof -package command -package entity - -import akka.persistence.typed.scaladsl.EventSourcedBehavior -import akka.persistence.typed.PersistenceId -import akka.actor.typed.Behavior -import akka.actor.typed.ActorRef -import akka.pattern.StatusReply -import akka.Done - -import fiftyforms.akka.* -import akka.persistence.typed.scaladsl.Effect - -object ProofBehaviour: - - type ReplyTo = ActorRef[StatusReply[Done]] - - case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) - - type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] - - type ProofReplyEffect = - akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] - - def apply(persistenceId: PersistenceId): Behavior[ProofCommand] = - import ProofEventHandler.* - EventSourcedBehavior - .withEnforcedReplies[ProofCommand, ProofEvent, State]( - persistenceId = persistenceId, - emptyState = None, - commandHandler = handleProofCommand, - eventHandler = (state, event) => - state.handleEvent(event).orElse(unhandledEvent(event, state)) - ) - .withTagger(_ => Set(ProofEvent.Tag)) - - def handleProofCommand( - state: State, - command: ProofCommand - ): ProofReplyEffect = - handleCommand(state, command.command) match - case Some(events) => persist(events, command.meta, command.replyTo) - case _ => unhandled(command.command, command.replyTo) - - def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = - state match - case None => - cmd match - case CreateProof( - id, - person, - parameterId, - criterionId, - documents, - Authorized(note) - ) => - Some( - Seq( - ProofCreated(id, person, parameterId, criterionId, documents), - ProofAuthorized(id, note) - ) - ) - case CreateProof( - id, - person, - parameterId, - criterionId, - documents, - _ - ) => - Some( - Seq(ProofCreated(id, person, parameterId, criterionId, documents)) - ) - case _ => None - case Some(proof) if proof.isRevoked => None - case Some(proof) if proof.isAuthorized => - cmd match - case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) - case RevokeProof(id, reason, since, documents) => - Some(Seq((ProofRevoked(id, reason, since, documents)))) - case _ => None - case Some(proof) => - cmd match - case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) - case UpdateProof(id, documents) => - Some(Seq(ProofUpdated(id, documents))) - case RevokeProof(id, reason, since, documents) => - Some(Seq((ProofRevoked(id, reason, since, documents)))) - case _ => None - - private def persist( - events: Seq[Event], - meta: WW, - replyTo: ReplyTo - ): ProofReplyEffect = - Effect - .persist(events.map(ProofEvent(_, meta))) - .thenReply(replyTo)(_ => StatusReply.Ack) - - private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = - Effect.unhandled.thenReply(replyTo)(s => - StatusReply.error(CommandNotAvailable(command, s)) - ) - - private def unhandledEvent(event: ProofEvent, state: State): Nothing = - throw UnhandledEvent(event, state) diff --git a/domain/proof/command/entity/src/ProofCommandBus.scala b/domain/proof/command/entity/src/ProofCommandBus.scala new file mode 100644 index 0000000..3167e02 --- /dev/null +++ b/domain/proof/command/entity/src/ProofCommandBus.scala @@ -0,0 +1,50 @@ +package mdr.pdb +package proof +package command +package entity + +import zio.* +import akka.actor.typed.ActorSystem +import akka.cluster.sharding.typed.scaladsl.EntityContext +import akka.actor.typed.Behavior +import akka.cluster.sharding.typed.scaladsl.ClusterSharding +import akka.util.Timeout +import java.time.Instant + +object ProofCommandBus: + def submitCommand(command: Command): RIO[ProofCommandBus, Unit] = + ZIO.serviceWithZIO(_.submitCommand(command)) + + val layer: RLayer[ActorSystem[_], ProofCommandBus] = + ZIO + .serviceWithZIO[ActorSystem[_]](system => + for + timeout <- Task.attempt( + Timeout.create( + system.settings.config.getDuration("proof-bus.timeout") + ) + ) + // TODO: init only once + _ <- Task.attempt(ProofBehavior.init(system)) + yield ProofCommandBus(system)(using timeout) + ) + .toLayer + +class ProofCommandBus(system: ActorSystem[_])(using timeout: Timeout): + private val sharding = ClusterSharding(system) + + def submitCommand(command: Command): Task[Unit] = + for + entityRef <- Task.attempt( + sharding.entityRefFor(ProofBehavior.EntityKey, command.id) + ) + reply <- ZIO.fromFuture(_ => + entityRef.askWithStatus( + ProofBehavior.ProofCommand( + command, + WhoWhen(OsobniCislo("0123"), Instant.now()), + _ + ) + ) + ) + yield () diff --git a/domain/proof/command/json/src/Codecs.scala b/domain/proof/command/json/src/Codecs.scala deleted file mode 100644 index 6867615..0000000 --- a/domain/proof/command/json/src/Codecs.scala +++ /dev/null @@ -1,13 +0,0 @@ -package mdr.pdb -package proof.command -package json - -import zio.json.* - -trait Codecs extends proof.json.Codecs: - - given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen - given JsonCodec[CreateProof] = DeriveJsonCodec.gen - given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen - given JsonCodec[UpdateProof] = DeriveJsonCodec.gen - given JsonCodec[RevokeProof] = DeriveJsonCodec.gen diff --git a/domain/proof/command/model/src/Command.scala b/domain/proof/command/model/src/Command.scala index 35a15af..1bff3cd 100644 --- a/domain/proof/command/model/src/Command.scala +++ b/domain/proof/command/model/src/Command.scala @@ -5,7 +5,8 @@ import java.time.LocalDate import java.time.Instant -sealed trait Command +sealed trait Command: + def id: Proof.Id sealed trait AuthorizeOption case object Unauthorized extends AuthorizeOption diff --git a/domain/proof/query/api/src/ProofQueryApi.scala b/domain/proof/query/api/src/ProofQueryApi.scala new file mode 100644 index 0000000..19acd07 --- /dev/null +++ b/domain/proof/query/api/src/ProofQueryApi.scala @@ -0,0 +1,16 @@ +package mdr.pdb.proof.query +package api + +import endpoints.Endpoints +import repo.ProofRepository +import fiftyforms.tapir.CustomTapir +import fiftyforms.tapir.InternalServerError + +object ProofQueryApi extends CustomTapir: + + val forUser: ZServerEndpoint[ProofRepository, Any] = + Endpoints.forUser.zServerLogic(osc => + ProofRepository + .matching(ProofRepository.OfPerson(osc)) + .mapError(InternalServerError.fromThrowable) + ) diff --git a/domain/proof/query/endpoints/src/Endpoints.scala b/domain/proof/query/endpoints/src/Endpoints.scala new file mode 100644 index 0000000..e3d9fb9 --- /dev/null +++ b/domain/proof/query/endpoints/src/Endpoints.scala @@ -0,0 +1,21 @@ +package mdr.pdb +package proof +package query.endpoints + +import fiftyforms.tapir.CustomTapir +import mdr.pdb.proof.codecs.Codecs +import fiftyforms.tapir.ServerError +import zio.json.JsonCodec +import zio.json.DeriveJsonCodec + +object Endpoints + extends mdr.pdb.endpoints.Endpoints + with CustomTapir + with Codecs: + + val forUser: Endpoint[Unit, OsobniCislo, ServerError, Seq[Proof], Any] = + endpoint + .in("proof") + .in(path[OsobniCislo]("osobniCislo")) + .out(jsonBody[Seq[Proof]]) + .errorOut(jsonBody[ServerError]) diff --git a/domain/proof/query/repo/src/MongoProofRepository.scala b/domain/proof/query/repo/src/MongoProofRepository.scala index fad055c..cf19e7a 100644 --- a/domain/proof/query/repo/src/MongoProofRepository.scala +++ b/domain/proof/query/repo/src/MongoProofRepository.scala @@ -9,6 +9,7 @@ import org.bson.json.JsonObject import fiftyforms.mongo.MongoJsonRepository +// TODO: extract common mongo repo config, just nest under mongo / case class MongoProofConfig(db: String, collection: String) object MongoProofConfig { @@ -47,7 +48,7 @@ collection: MongoCollection[JsonObject] ) extends ProofRepositoryWrite: import ProofRepository.* - import mdr.pdb.proof.json.Codecs.given + import mdr.pdb.proof.codecs.Codecs.given private val jsonRepo = MongoJsonRepository[Proof, String, Criteria]( collection, { diff --git a/domain/proof/shared/codecs/src/Codecs.scala b/domain/proof/shared/codecs/src/Codecs.scala new file mode 100644 index 0000000..068f37b --- /dev/null +++ b/domain/proof/shared/codecs/src/Codecs.scala @@ -0,0 +1,20 @@ +package mdr.pdb.proof +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends mdr.pdb.codecs.JsonCodecs: + given JsonCodec[Authorization] = DeriveJsonCodec.gen + given JsonCodec[RevocationReason] = DeriveJsonCodec.gen + given JsonCodec[Revocation] = DeriveJsonCodec.gen + given JsonCodec[Proof] = DeriveJsonCodec.gen + +trait TapirCodecs extends mdr.pdb.codecs.TapirCodecs: + given Schema[Authorization] = Schema.derived + given Schema[RevocationReason] = Schema.derived + given Schema[Revocation] = Schema.derived + given Schema[Proof] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/proof/shared/json/src/Codecs.scala b/domain/proof/shared/json/src/Codecs.scala deleted file mode 100644 index 251550e..0000000 --- a/domain/proof/shared/json/src/Codecs.scala +++ /dev/null @@ -1,13 +0,0 @@ -package mdr.pdb.proof -package json - -import zio.json.* - -trait Codecs extends mdr.pdb.json.Codecs: - - given JsonCodec[Authorization] = DeriveJsonCodec.gen - given JsonCodec[RevocationReason] = DeriveJsonCodec.gen - given JsonCodec[Revocation] = DeriveJsonCodec.gen - given JsonCodec[Proof] = DeriveJsonCodec.gen - -object Codecs extends Codecs diff --git a/build.sbt b/build.sbt index e6f5e40..227e292 100644 --- a/build.sbt +++ b/build.sbt @@ -14,38 +14,38 @@ lazy val proof = entityProject("proof", file("domain/proof")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .repo(_.dependsOn(`mongo-support`)) .entity(_.dependsOn(`akka-persistence-support`)) .projection(_.dependsOn(`akka-persistence-support`)) - .endpoints(_.dependsOn(`tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val parameters = entityProject("parameters", file("domain/parameters")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) - .endpoints(_.dependsOn(`tapir-support`)) + .codecs(_.dependsOn(codecs, `tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val users = entityProject("users", file("domain/users")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val core = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("core")) -lazy val json = crossProject(JSPlatform, JVMPlatform) +lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) - .in(file("json")) + .in(file("codecs")) .settings(IWDeps.zioJson) - .dependsOn(core) + .dependsOn(core, `tapir-support`) lazy val endpoints = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("endpoints")) - .dependsOn(core, json, `tapir-support`) + .dependsOn(core, codecs, `tapir-support`) // TODO: move all from fiftyforms to iterative works lazy val ui = (project in file("fiftyforms/ui")) @@ -80,7 +80,11 @@ lazy val `akka-persistence-support` = project .in(file("fiftyforms/akka-persistence")) - .settings(IWDeps.akka.profiles.eventsourcedJdbcProjection) + .settings( + IWDeps.akka.libs.persistence, + libraryDependencies += "com.typesafe.akka" %% "akka-cluster-sharding-typed" % IWDeps.akka.V, + IWDeps.akka.profiles.eventsourcedJdbcProjection + ) lazy val app = (project in file("app")) .enablePlugins(ScalaJSPlugin, VitePlugin) @@ -166,6 +170,7 @@ proof.query.api, proof.command.api, proof.query.projection, + proof.command.entity, endpoints.jvm ) diff --git a/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala new file mode 100644 index 0000000..22ebfb8 --- /dev/null +++ b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala @@ -0,0 +1,26 @@ +package mdr.pdb +package codecs + +import zio.json.* +import fiftyforms.tapir.CustomTapir + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + given JsonCodec[WhoWhen] = DeriveJsonCodec.gen + given JsonCodec[OsobniCislo] = + JsonCodec.string.transform(OsobniCislo.apply, _.toString) + given JsonFieldEncoder[OsobniCislo] = + JsonFieldEncoder.string.contramap(_.toString) + given JsonFieldDecoder[OsobniCislo] = + JsonFieldDecoder.string.map(OsobniCislo(_)) + +trait TapirCodecs extends CustomTapir: + given Schema[OsobniCislo] = Schema.string + given Codec.PlainCodec[OsobniCislo] = + Codec.string.mapDecode(OsobniCislo.apply andThen DecodeResult.Value.apply)( + _.toString + ) + given Schema[WhoWhen] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/parameters/shared/codecs/src/Codecs.scala b/domain/parameters/shared/codecs/src/Codecs.scala new file mode 100644 index 0000000..751e79b --- /dev/null +++ b/domain/parameters/shared/codecs/src/Codecs.scala @@ -0,0 +1,10 @@ +package mdr.pdb +package parameters +package codecs + +import zio.json.* + +trait Codecs: + + given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen + given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/parameters/shared/json/src/Codecs.scala b/domain/parameters/shared/json/src/Codecs.scala deleted file mode 100644 index b849072..0000000 --- a/domain/parameters/shared/json/src/Codecs.scala +++ /dev/null @@ -1,10 +0,0 @@ -package mdr.pdb -package parameters -package json - -import zio.json.* - -trait Codecs: - - given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen - given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/proof/command/api/src/ProofCommandApi.scala b/domain/proof/command/api/src/ProofCommandApi.scala new file mode 100644 index 0000000..12f747e --- /dev/null +++ b/domain/proof/command/api/src/ProofCommandApi.scala @@ -0,0 +1,16 @@ +package mdr.pdb.proof.command +package api + +import endpoints.Endpoints +import entity.ProofCommandBus +import fiftyforms.tapir.CustomTapir +import fiftyforms.tapir.InternalServerError + +object ProofCommandApi extends CustomTapir: + + val submitCommand: ZServerEndpoint[ProofCommandBus, Any] = + Endpoints.submitCommand.zServerLogic(cmd => + ProofCommandBus + .submitCommand(cmd) + .mapError(InternalServerError.fromThrowable) + ) diff --git a/domain/proof/command/codecs/src/Codecs.scala b/domain/proof/command/codecs/src/Codecs.scala new file mode 100644 index 0000000..0e75f82 --- /dev/null +++ b/domain/proof/command/codecs/src/Codecs.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof.command +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends proof.codecs.JsonCodecs: + + given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen + given JsonCodec[CreateProof] = DeriveJsonCodec.gen + given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen + given JsonCodec[UpdateProof] = DeriveJsonCodec.gen + given JsonCodec[RevokeProof] = DeriveJsonCodec.gen + given JsonCodec[Command] = DeriveJsonCodec.gen + +trait TapirCodecs extends proof.codecs.TapirCodecs: + + given Schema[AuthorizeOption] = Schema.derived + given Schema[CreateProof] = Schema.derived + given Schema[AuthorizeProof] = Schema.derived + given Schema[UpdateProof] = Schema.derived + given Schema[RevokeProof] = Schema.derived + given Schema[Command] = Schema.derived diff --git a/domain/proof/command/endpoints/src/Endpoints.scala b/domain/proof/command/endpoints/src/Endpoints.scala new file mode 100644 index 0000000..dcd114e --- /dev/null +++ b/domain/proof/command/endpoints/src/Endpoints.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof +package command +package endpoints + +import fiftyforms.tapir.CustomTapir +import mdr.pdb.proof.command.codecs.Codecs +import fiftyforms.tapir.ServerError +import zio.json.JsonCodec +import zio.json.DeriveJsonCodec +import sttp.model.StatusCode + +object Endpoints + extends mdr.pdb.endpoints.Endpoints + with CustomTapir + with Codecs: + + val submitCommand: Endpoint[Unit, Command, ServerError, Unit, Any] = + endpoint + .in("command") + .in("proof") + .post + .in(jsonBody[Command]) + .out(statusCode(StatusCode.Accepted)) + .errorOut(jsonBody[ServerError]) diff --git a/domain/proof/command/entity/src/ProofBehavior.scala b/domain/proof/command/entity/src/ProofBehavior.scala new file mode 100644 index 0000000..f679981 --- /dev/null +++ b/domain/proof/command/entity/src/ProofBehavior.scala @@ -0,0 +1,120 @@ +package mdr.pdb +package proof +package command +package entity + +import akka.persistence.typed.scaladsl.Effect +import akka.persistence.typed.scaladsl.EventSourcedBehavior +import akka.persistence.typed.PersistenceId +import akka.actor.typed.Behavior +import akka.actor.typed.ActorRef +import akka.pattern.StatusReply +import akka.Done + +import fiftyforms.akka.* +import akka.actor.typed.ActorSystem +import akka.cluster.sharding.typed.scaladsl.EntityTypeKey +import akka.cluster.sharding.typed.scaladsl.EntityContext +import akka.cluster.sharding.typed.scaladsl.ClusterSharding +import akka.cluster.sharding.typed.scaladsl.Entity + +object ProofBehavior: + + type ReplyTo = ActorRef[StatusReply[Done]] + + case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) + + type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] + + type ProofReplyEffect = + akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] + + val EntityKey: EntityTypeKey[ProofCommand] = EntityTypeKey("Proof") + + def init(system: ActorSystem[_]): Unit = + val behaviorFactory: EntityContext[ProofCommand] => Behavior[ProofCommand] = + entityContext => ProofBehavior(entityContext.entityId) + ClusterSharding(system).init(Entity(EntityKey)(behaviorFactory)) + + def apply(persistenceId: String): Behavior[ProofCommand] = + import ProofEventHandler.* + EventSourcedBehavior + .withEnforcedReplies[ProofCommand, ProofEvent, State]( + persistenceId = PersistenceId.ofUniqueId(persistenceId), + emptyState = None, + commandHandler = handleProofCommand, + eventHandler = (state, event) => + state.handleEvent(event).orElse(unhandledEvent(event, state)) + ) + .withTagger(_ => Set(ProofEvent.Tag)) + + def handleProofCommand( + state: State, + command: ProofCommand + ): ProofReplyEffect = + handleCommand(state, command.command) match + case Some(events) => persist(events, command.meta, command.replyTo) + case _ => unhandled(command.command, command.replyTo) + + def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = + state match + case None => + cmd match + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + Authorized(note) + ) => + Some( + Seq( + ProofCreated(id, person, parameterId, criterionId, documents), + ProofAuthorized(id, note) + ) + ) + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + _ + ) => + Some( + Seq(ProofCreated(id, person, parameterId, criterionId, documents)) + ) + case _ => None + case Some(proof) if proof.isRevoked => None + case Some(proof) if proof.isAuthorized => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + case Some(proof) => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case UpdateProof(id, documents) => + Some(Seq(ProofUpdated(id, documents))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + + private def persist( + events: Seq[Event], + meta: WW, + replyTo: ReplyTo + ): ProofReplyEffect = + Effect + .persist(events.map(ProofEvent(_, meta))) + .thenReply(replyTo)(_ => StatusReply.Ack) + + private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = + Effect.unhandled.thenReply(replyTo)(s => + StatusReply.error(CommandNotAvailable(command, s)) + ) + + private def unhandledEvent(event: ProofEvent, state: State): Nothing = + throw UnhandledEvent(event, state) diff --git a/domain/proof/command/entity/src/ProofBehaviour.scala b/domain/proof/command/entity/src/ProofBehaviour.scala deleted file mode 100644 index e8c7e4e..0000000 --- a/domain/proof/command/entity/src/ProofBehaviour.scala +++ /dev/null @@ -1,108 +0,0 @@ -package mdr.pdb -package proof -package command -package entity - -import akka.persistence.typed.scaladsl.EventSourcedBehavior -import akka.persistence.typed.PersistenceId -import akka.actor.typed.Behavior -import akka.actor.typed.ActorRef -import akka.pattern.StatusReply -import akka.Done - -import fiftyforms.akka.* -import akka.persistence.typed.scaladsl.Effect - -object ProofBehaviour: - - type ReplyTo = ActorRef[StatusReply[Done]] - - case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) - - type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] - - type ProofReplyEffect = - akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] - - def apply(persistenceId: PersistenceId): Behavior[ProofCommand] = - import ProofEventHandler.* - EventSourcedBehavior - .withEnforcedReplies[ProofCommand, ProofEvent, State]( - persistenceId = persistenceId, - emptyState = None, - commandHandler = handleProofCommand, - eventHandler = (state, event) => - state.handleEvent(event).orElse(unhandledEvent(event, state)) - ) - .withTagger(_ => Set(ProofEvent.Tag)) - - def handleProofCommand( - state: State, - command: ProofCommand - ): ProofReplyEffect = - handleCommand(state, command.command) match - case Some(events) => persist(events, command.meta, command.replyTo) - case _ => unhandled(command.command, command.replyTo) - - def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = - state match - case None => - cmd match - case CreateProof( - id, - person, - parameterId, - criterionId, - documents, - Authorized(note) - ) => - Some( - Seq( - ProofCreated(id, person, parameterId, criterionId, documents), - ProofAuthorized(id, note) - ) - ) - case CreateProof( - id, - person, - parameterId, - criterionId, - documents, - _ - ) => - Some( - Seq(ProofCreated(id, person, parameterId, criterionId, documents)) - ) - case _ => None - case Some(proof) if proof.isRevoked => None - case Some(proof) if proof.isAuthorized => - cmd match - case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) - case RevokeProof(id, reason, since, documents) => - Some(Seq((ProofRevoked(id, reason, since, documents)))) - case _ => None - case Some(proof) => - cmd match - case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) - case UpdateProof(id, documents) => - Some(Seq(ProofUpdated(id, documents))) - case RevokeProof(id, reason, since, documents) => - Some(Seq((ProofRevoked(id, reason, since, documents)))) - case _ => None - - private def persist( - events: Seq[Event], - meta: WW, - replyTo: ReplyTo - ): ProofReplyEffect = - Effect - .persist(events.map(ProofEvent(_, meta))) - .thenReply(replyTo)(_ => StatusReply.Ack) - - private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = - Effect.unhandled.thenReply(replyTo)(s => - StatusReply.error(CommandNotAvailable(command, s)) - ) - - private def unhandledEvent(event: ProofEvent, state: State): Nothing = - throw UnhandledEvent(event, state) diff --git a/domain/proof/command/entity/src/ProofCommandBus.scala b/domain/proof/command/entity/src/ProofCommandBus.scala new file mode 100644 index 0000000..3167e02 --- /dev/null +++ b/domain/proof/command/entity/src/ProofCommandBus.scala @@ -0,0 +1,50 @@ +package mdr.pdb +package proof +package command +package entity + +import zio.* +import akka.actor.typed.ActorSystem +import akka.cluster.sharding.typed.scaladsl.EntityContext +import akka.actor.typed.Behavior +import akka.cluster.sharding.typed.scaladsl.ClusterSharding +import akka.util.Timeout +import java.time.Instant + +object ProofCommandBus: + def submitCommand(command: Command): RIO[ProofCommandBus, Unit] = + ZIO.serviceWithZIO(_.submitCommand(command)) + + val layer: RLayer[ActorSystem[_], ProofCommandBus] = + ZIO + .serviceWithZIO[ActorSystem[_]](system => + for + timeout <- Task.attempt( + Timeout.create( + system.settings.config.getDuration("proof-bus.timeout") + ) + ) + // TODO: init only once + _ <- Task.attempt(ProofBehavior.init(system)) + yield ProofCommandBus(system)(using timeout) + ) + .toLayer + +class ProofCommandBus(system: ActorSystem[_])(using timeout: Timeout): + private val sharding = ClusterSharding(system) + + def submitCommand(command: Command): Task[Unit] = + for + entityRef <- Task.attempt( + sharding.entityRefFor(ProofBehavior.EntityKey, command.id) + ) + reply <- ZIO.fromFuture(_ => + entityRef.askWithStatus( + ProofBehavior.ProofCommand( + command, + WhoWhen(OsobniCislo("0123"), Instant.now()), + _ + ) + ) + ) + yield () diff --git a/domain/proof/command/json/src/Codecs.scala b/domain/proof/command/json/src/Codecs.scala deleted file mode 100644 index 6867615..0000000 --- a/domain/proof/command/json/src/Codecs.scala +++ /dev/null @@ -1,13 +0,0 @@ -package mdr.pdb -package proof.command -package json - -import zio.json.* - -trait Codecs extends proof.json.Codecs: - - given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen - given JsonCodec[CreateProof] = DeriveJsonCodec.gen - given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen - given JsonCodec[UpdateProof] = DeriveJsonCodec.gen - given JsonCodec[RevokeProof] = DeriveJsonCodec.gen diff --git a/domain/proof/command/model/src/Command.scala b/domain/proof/command/model/src/Command.scala index 35a15af..1bff3cd 100644 --- a/domain/proof/command/model/src/Command.scala +++ b/domain/proof/command/model/src/Command.scala @@ -5,7 +5,8 @@ import java.time.LocalDate import java.time.Instant -sealed trait Command +sealed trait Command: + def id: Proof.Id sealed trait AuthorizeOption case object Unauthorized extends AuthorizeOption diff --git a/domain/proof/query/api/src/ProofQueryApi.scala b/domain/proof/query/api/src/ProofQueryApi.scala new file mode 100644 index 0000000..19acd07 --- /dev/null +++ b/domain/proof/query/api/src/ProofQueryApi.scala @@ -0,0 +1,16 @@ +package mdr.pdb.proof.query +package api + +import endpoints.Endpoints +import repo.ProofRepository +import fiftyforms.tapir.CustomTapir +import fiftyforms.tapir.InternalServerError + +object ProofQueryApi extends CustomTapir: + + val forUser: ZServerEndpoint[ProofRepository, Any] = + Endpoints.forUser.zServerLogic(osc => + ProofRepository + .matching(ProofRepository.OfPerson(osc)) + .mapError(InternalServerError.fromThrowable) + ) diff --git a/domain/proof/query/endpoints/src/Endpoints.scala b/domain/proof/query/endpoints/src/Endpoints.scala new file mode 100644 index 0000000..e3d9fb9 --- /dev/null +++ b/domain/proof/query/endpoints/src/Endpoints.scala @@ -0,0 +1,21 @@ +package mdr.pdb +package proof +package query.endpoints + +import fiftyforms.tapir.CustomTapir +import mdr.pdb.proof.codecs.Codecs +import fiftyforms.tapir.ServerError +import zio.json.JsonCodec +import zio.json.DeriveJsonCodec + +object Endpoints + extends mdr.pdb.endpoints.Endpoints + with CustomTapir + with Codecs: + + val forUser: Endpoint[Unit, OsobniCislo, ServerError, Seq[Proof], Any] = + endpoint + .in("proof") + .in(path[OsobniCislo]("osobniCislo")) + .out(jsonBody[Seq[Proof]]) + .errorOut(jsonBody[ServerError]) diff --git a/domain/proof/query/repo/src/MongoProofRepository.scala b/domain/proof/query/repo/src/MongoProofRepository.scala index fad055c..cf19e7a 100644 --- a/domain/proof/query/repo/src/MongoProofRepository.scala +++ b/domain/proof/query/repo/src/MongoProofRepository.scala @@ -9,6 +9,7 @@ import org.bson.json.JsonObject import fiftyforms.mongo.MongoJsonRepository +// TODO: extract common mongo repo config, just nest under mongo / case class MongoProofConfig(db: String, collection: String) object MongoProofConfig { @@ -47,7 +48,7 @@ collection: MongoCollection[JsonObject] ) extends ProofRepositoryWrite: import ProofRepository.* - import mdr.pdb.proof.json.Codecs.given + import mdr.pdb.proof.codecs.Codecs.given private val jsonRepo = MongoJsonRepository[Proof, String, Criteria]( collection, { diff --git a/domain/proof/shared/codecs/src/Codecs.scala b/domain/proof/shared/codecs/src/Codecs.scala new file mode 100644 index 0000000..068f37b --- /dev/null +++ b/domain/proof/shared/codecs/src/Codecs.scala @@ -0,0 +1,20 @@ +package mdr.pdb.proof +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends mdr.pdb.codecs.JsonCodecs: + given JsonCodec[Authorization] = DeriveJsonCodec.gen + given JsonCodec[RevocationReason] = DeriveJsonCodec.gen + given JsonCodec[Revocation] = DeriveJsonCodec.gen + given JsonCodec[Proof] = DeriveJsonCodec.gen + +trait TapirCodecs extends mdr.pdb.codecs.TapirCodecs: + given Schema[Authorization] = Schema.derived + given Schema[RevocationReason] = Schema.derived + given Schema[Revocation] = Schema.derived + given Schema[Proof] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/proof/shared/json/src/Codecs.scala b/domain/proof/shared/json/src/Codecs.scala deleted file mode 100644 index 251550e..0000000 --- a/domain/proof/shared/json/src/Codecs.scala +++ /dev/null @@ -1,13 +0,0 @@ -package mdr.pdb.proof -package json - -import zio.json.* - -trait Codecs extends mdr.pdb.json.Codecs: - - given JsonCodec[Authorization] = DeriveJsonCodec.gen - given JsonCodec[RevocationReason] = DeriveJsonCodec.gen - given JsonCodec[Revocation] = DeriveJsonCodec.gen - given JsonCodec[Proof] = DeriveJsonCodec.gen - -object Codecs extends Codecs diff --git a/domain/users/query/codecs/src/Codecs.scala b/domain/users/query/codecs/src/Codecs.scala new file mode 100644 index 0000000..d30f318 --- /dev/null +++ b/domain/users/query/codecs/src/Codecs.scala @@ -0,0 +1,21 @@ +package mdr.pdb +package users.query +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends mdr.pdb.codecs.JsonCodecs: + given JsonCodec[UserContract] = DeriveJsonCodec.gen + given JsonCodec[UserFunction] = DeriveJsonCodec.gen + given JsonCodec[UserInfo] = DeriveJsonCodec.gen + given JsonCodec[UserProfile] = DeriveJsonCodec.gen + +trait TapirCodecs extends mdr.pdb.codecs.TapirCodecs: + given Schema[UserContract] = Schema.derived + given Schema[UserFunction] = Schema.derived + given Schema[UserInfo] = Schema.derived + given Schema[UserProfile] = Schema.derived + +object Codecs extends Codecs diff --git a/build.sbt b/build.sbt index e6f5e40..227e292 100644 --- a/build.sbt +++ b/build.sbt @@ -14,38 +14,38 @@ lazy val proof = entityProject("proof", file("domain/proof")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .repo(_.dependsOn(`mongo-support`)) .entity(_.dependsOn(`akka-persistence-support`)) .projection(_.dependsOn(`akka-persistence-support`)) - .endpoints(_.dependsOn(`tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val parameters = entityProject("parameters", file("domain/parameters")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) - .endpoints(_.dependsOn(`tapir-support`)) + .codecs(_.dependsOn(codecs, `tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val users = entityProject("users", file("domain/users")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val core = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("core")) -lazy val json = crossProject(JSPlatform, JVMPlatform) +lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) - .in(file("json")) + .in(file("codecs")) .settings(IWDeps.zioJson) - .dependsOn(core) + .dependsOn(core, `tapir-support`) lazy val endpoints = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("endpoints")) - .dependsOn(core, json, `tapir-support`) + .dependsOn(core, codecs, `tapir-support`) // TODO: move all from fiftyforms to iterative works lazy val ui = (project in file("fiftyforms/ui")) @@ -80,7 +80,11 @@ lazy val `akka-persistence-support` = project .in(file("fiftyforms/akka-persistence")) - .settings(IWDeps.akka.profiles.eventsourcedJdbcProjection) + .settings( + IWDeps.akka.libs.persistence, + libraryDependencies += "com.typesafe.akka" %% "akka-cluster-sharding-typed" % IWDeps.akka.V, + IWDeps.akka.profiles.eventsourcedJdbcProjection + ) lazy val app = (project in file("app")) .enablePlugins(ScalaJSPlugin, VitePlugin) @@ -166,6 +170,7 @@ proof.query.api, proof.command.api, proof.query.projection, + proof.command.entity, endpoints.jvm ) diff --git a/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala new file mode 100644 index 0000000..22ebfb8 --- /dev/null +++ b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala @@ -0,0 +1,26 @@ +package mdr.pdb +package codecs + +import zio.json.* +import fiftyforms.tapir.CustomTapir + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + given JsonCodec[WhoWhen] = DeriveJsonCodec.gen + given JsonCodec[OsobniCislo] = + JsonCodec.string.transform(OsobniCislo.apply, _.toString) + given JsonFieldEncoder[OsobniCislo] = + JsonFieldEncoder.string.contramap(_.toString) + given JsonFieldDecoder[OsobniCislo] = + JsonFieldDecoder.string.map(OsobniCislo(_)) + +trait TapirCodecs extends CustomTapir: + given Schema[OsobniCislo] = Schema.string + given Codec.PlainCodec[OsobniCislo] = + Codec.string.mapDecode(OsobniCislo.apply andThen DecodeResult.Value.apply)( + _.toString + ) + given Schema[WhoWhen] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/parameters/shared/codecs/src/Codecs.scala b/domain/parameters/shared/codecs/src/Codecs.scala new file mode 100644 index 0000000..751e79b --- /dev/null +++ b/domain/parameters/shared/codecs/src/Codecs.scala @@ -0,0 +1,10 @@ +package mdr.pdb +package parameters +package codecs + +import zio.json.* + +trait Codecs: + + given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen + given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/parameters/shared/json/src/Codecs.scala b/domain/parameters/shared/json/src/Codecs.scala deleted file mode 100644 index b849072..0000000 --- a/domain/parameters/shared/json/src/Codecs.scala +++ /dev/null @@ -1,10 +0,0 @@ -package mdr.pdb -package parameters -package json - -import zio.json.* - -trait Codecs: - - given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen - given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/proof/command/api/src/ProofCommandApi.scala b/domain/proof/command/api/src/ProofCommandApi.scala new file mode 100644 index 0000000..12f747e --- /dev/null +++ b/domain/proof/command/api/src/ProofCommandApi.scala @@ -0,0 +1,16 @@ +package mdr.pdb.proof.command +package api + +import endpoints.Endpoints +import entity.ProofCommandBus +import fiftyforms.tapir.CustomTapir +import fiftyforms.tapir.InternalServerError + +object ProofCommandApi extends CustomTapir: + + val submitCommand: ZServerEndpoint[ProofCommandBus, Any] = + Endpoints.submitCommand.zServerLogic(cmd => + ProofCommandBus + .submitCommand(cmd) + .mapError(InternalServerError.fromThrowable) + ) diff --git a/domain/proof/command/codecs/src/Codecs.scala b/domain/proof/command/codecs/src/Codecs.scala new file mode 100644 index 0000000..0e75f82 --- /dev/null +++ b/domain/proof/command/codecs/src/Codecs.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof.command +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends proof.codecs.JsonCodecs: + + given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen + given JsonCodec[CreateProof] = DeriveJsonCodec.gen + given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen + given JsonCodec[UpdateProof] = DeriveJsonCodec.gen + given JsonCodec[RevokeProof] = DeriveJsonCodec.gen + given JsonCodec[Command] = DeriveJsonCodec.gen + +trait TapirCodecs extends proof.codecs.TapirCodecs: + + given Schema[AuthorizeOption] = Schema.derived + given Schema[CreateProof] = Schema.derived + given Schema[AuthorizeProof] = Schema.derived + given Schema[UpdateProof] = Schema.derived + given Schema[RevokeProof] = Schema.derived + given Schema[Command] = Schema.derived diff --git a/domain/proof/command/endpoints/src/Endpoints.scala b/domain/proof/command/endpoints/src/Endpoints.scala new file mode 100644 index 0000000..dcd114e --- /dev/null +++ b/domain/proof/command/endpoints/src/Endpoints.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof +package command +package endpoints + +import fiftyforms.tapir.CustomTapir +import mdr.pdb.proof.command.codecs.Codecs +import fiftyforms.tapir.ServerError +import zio.json.JsonCodec +import zio.json.DeriveJsonCodec +import sttp.model.StatusCode + +object Endpoints + extends mdr.pdb.endpoints.Endpoints + with CustomTapir + with Codecs: + + val submitCommand: Endpoint[Unit, Command, ServerError, Unit, Any] = + endpoint + .in("command") + .in("proof") + .post + .in(jsonBody[Command]) + .out(statusCode(StatusCode.Accepted)) + .errorOut(jsonBody[ServerError]) diff --git a/domain/proof/command/entity/src/ProofBehavior.scala b/domain/proof/command/entity/src/ProofBehavior.scala new file mode 100644 index 0000000..f679981 --- /dev/null +++ b/domain/proof/command/entity/src/ProofBehavior.scala @@ -0,0 +1,120 @@ +package mdr.pdb +package proof +package command +package entity + +import akka.persistence.typed.scaladsl.Effect +import akka.persistence.typed.scaladsl.EventSourcedBehavior +import akka.persistence.typed.PersistenceId +import akka.actor.typed.Behavior +import akka.actor.typed.ActorRef +import akka.pattern.StatusReply +import akka.Done + +import fiftyforms.akka.* +import akka.actor.typed.ActorSystem +import akka.cluster.sharding.typed.scaladsl.EntityTypeKey +import akka.cluster.sharding.typed.scaladsl.EntityContext +import akka.cluster.sharding.typed.scaladsl.ClusterSharding +import akka.cluster.sharding.typed.scaladsl.Entity + +object ProofBehavior: + + type ReplyTo = ActorRef[StatusReply[Done]] + + case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) + + type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] + + type ProofReplyEffect = + akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] + + val EntityKey: EntityTypeKey[ProofCommand] = EntityTypeKey("Proof") + + def init(system: ActorSystem[_]): Unit = + val behaviorFactory: EntityContext[ProofCommand] => Behavior[ProofCommand] = + entityContext => ProofBehavior(entityContext.entityId) + ClusterSharding(system).init(Entity(EntityKey)(behaviorFactory)) + + def apply(persistenceId: String): Behavior[ProofCommand] = + import ProofEventHandler.* + EventSourcedBehavior + .withEnforcedReplies[ProofCommand, ProofEvent, State]( + persistenceId = PersistenceId.ofUniqueId(persistenceId), + emptyState = None, + commandHandler = handleProofCommand, + eventHandler = (state, event) => + state.handleEvent(event).orElse(unhandledEvent(event, state)) + ) + .withTagger(_ => Set(ProofEvent.Tag)) + + def handleProofCommand( + state: State, + command: ProofCommand + ): ProofReplyEffect = + handleCommand(state, command.command) match + case Some(events) => persist(events, command.meta, command.replyTo) + case _ => unhandled(command.command, command.replyTo) + + def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = + state match + case None => + cmd match + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + Authorized(note) + ) => + Some( + Seq( + ProofCreated(id, person, parameterId, criterionId, documents), + ProofAuthorized(id, note) + ) + ) + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + _ + ) => + Some( + Seq(ProofCreated(id, person, parameterId, criterionId, documents)) + ) + case _ => None + case Some(proof) if proof.isRevoked => None + case Some(proof) if proof.isAuthorized => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + case Some(proof) => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case UpdateProof(id, documents) => + Some(Seq(ProofUpdated(id, documents))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + + private def persist( + events: Seq[Event], + meta: WW, + replyTo: ReplyTo + ): ProofReplyEffect = + Effect + .persist(events.map(ProofEvent(_, meta))) + .thenReply(replyTo)(_ => StatusReply.Ack) + + private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = + Effect.unhandled.thenReply(replyTo)(s => + StatusReply.error(CommandNotAvailable(command, s)) + ) + + private def unhandledEvent(event: ProofEvent, state: State): Nothing = + throw UnhandledEvent(event, state) diff --git a/domain/proof/command/entity/src/ProofBehaviour.scala b/domain/proof/command/entity/src/ProofBehaviour.scala deleted file mode 100644 index e8c7e4e..0000000 --- a/domain/proof/command/entity/src/ProofBehaviour.scala +++ /dev/null @@ -1,108 +0,0 @@ -package mdr.pdb -package proof -package command -package entity - -import akka.persistence.typed.scaladsl.EventSourcedBehavior -import akka.persistence.typed.PersistenceId -import akka.actor.typed.Behavior -import akka.actor.typed.ActorRef -import akka.pattern.StatusReply -import akka.Done - -import fiftyforms.akka.* -import akka.persistence.typed.scaladsl.Effect - -object ProofBehaviour: - - type ReplyTo = ActorRef[StatusReply[Done]] - - case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) - - type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] - - type ProofReplyEffect = - akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] - - def apply(persistenceId: PersistenceId): Behavior[ProofCommand] = - import ProofEventHandler.* - EventSourcedBehavior - .withEnforcedReplies[ProofCommand, ProofEvent, State]( - persistenceId = persistenceId, - emptyState = None, - commandHandler = handleProofCommand, - eventHandler = (state, event) => - state.handleEvent(event).orElse(unhandledEvent(event, state)) - ) - .withTagger(_ => Set(ProofEvent.Tag)) - - def handleProofCommand( - state: State, - command: ProofCommand - ): ProofReplyEffect = - handleCommand(state, command.command) match - case Some(events) => persist(events, command.meta, command.replyTo) - case _ => unhandled(command.command, command.replyTo) - - def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = - state match - case None => - cmd match - case CreateProof( - id, - person, - parameterId, - criterionId, - documents, - Authorized(note) - ) => - Some( - Seq( - ProofCreated(id, person, parameterId, criterionId, documents), - ProofAuthorized(id, note) - ) - ) - case CreateProof( - id, - person, - parameterId, - criterionId, - documents, - _ - ) => - Some( - Seq(ProofCreated(id, person, parameterId, criterionId, documents)) - ) - case _ => None - case Some(proof) if proof.isRevoked => None - case Some(proof) if proof.isAuthorized => - cmd match - case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) - case RevokeProof(id, reason, since, documents) => - Some(Seq((ProofRevoked(id, reason, since, documents)))) - case _ => None - case Some(proof) => - cmd match - case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) - case UpdateProof(id, documents) => - Some(Seq(ProofUpdated(id, documents))) - case RevokeProof(id, reason, since, documents) => - Some(Seq((ProofRevoked(id, reason, since, documents)))) - case _ => None - - private def persist( - events: Seq[Event], - meta: WW, - replyTo: ReplyTo - ): ProofReplyEffect = - Effect - .persist(events.map(ProofEvent(_, meta))) - .thenReply(replyTo)(_ => StatusReply.Ack) - - private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = - Effect.unhandled.thenReply(replyTo)(s => - StatusReply.error(CommandNotAvailable(command, s)) - ) - - private def unhandledEvent(event: ProofEvent, state: State): Nothing = - throw UnhandledEvent(event, state) diff --git a/domain/proof/command/entity/src/ProofCommandBus.scala b/domain/proof/command/entity/src/ProofCommandBus.scala new file mode 100644 index 0000000..3167e02 --- /dev/null +++ b/domain/proof/command/entity/src/ProofCommandBus.scala @@ -0,0 +1,50 @@ +package mdr.pdb +package proof +package command +package entity + +import zio.* +import akka.actor.typed.ActorSystem +import akka.cluster.sharding.typed.scaladsl.EntityContext +import akka.actor.typed.Behavior +import akka.cluster.sharding.typed.scaladsl.ClusterSharding +import akka.util.Timeout +import java.time.Instant + +object ProofCommandBus: + def submitCommand(command: Command): RIO[ProofCommandBus, Unit] = + ZIO.serviceWithZIO(_.submitCommand(command)) + + val layer: RLayer[ActorSystem[_], ProofCommandBus] = + ZIO + .serviceWithZIO[ActorSystem[_]](system => + for + timeout <- Task.attempt( + Timeout.create( + system.settings.config.getDuration("proof-bus.timeout") + ) + ) + // TODO: init only once + _ <- Task.attempt(ProofBehavior.init(system)) + yield ProofCommandBus(system)(using timeout) + ) + .toLayer + +class ProofCommandBus(system: ActorSystem[_])(using timeout: Timeout): + private val sharding = ClusterSharding(system) + + def submitCommand(command: Command): Task[Unit] = + for + entityRef <- Task.attempt( + sharding.entityRefFor(ProofBehavior.EntityKey, command.id) + ) + reply <- ZIO.fromFuture(_ => + entityRef.askWithStatus( + ProofBehavior.ProofCommand( + command, + WhoWhen(OsobniCislo("0123"), Instant.now()), + _ + ) + ) + ) + yield () diff --git a/domain/proof/command/json/src/Codecs.scala b/domain/proof/command/json/src/Codecs.scala deleted file mode 100644 index 6867615..0000000 --- a/domain/proof/command/json/src/Codecs.scala +++ /dev/null @@ -1,13 +0,0 @@ -package mdr.pdb -package proof.command -package json - -import zio.json.* - -trait Codecs extends proof.json.Codecs: - - given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen - given JsonCodec[CreateProof] = DeriveJsonCodec.gen - given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen - given JsonCodec[UpdateProof] = DeriveJsonCodec.gen - given JsonCodec[RevokeProof] = DeriveJsonCodec.gen diff --git a/domain/proof/command/model/src/Command.scala b/domain/proof/command/model/src/Command.scala index 35a15af..1bff3cd 100644 --- a/domain/proof/command/model/src/Command.scala +++ b/domain/proof/command/model/src/Command.scala @@ -5,7 +5,8 @@ import java.time.LocalDate import java.time.Instant -sealed trait Command +sealed trait Command: + def id: Proof.Id sealed trait AuthorizeOption case object Unauthorized extends AuthorizeOption diff --git a/domain/proof/query/api/src/ProofQueryApi.scala b/domain/proof/query/api/src/ProofQueryApi.scala new file mode 100644 index 0000000..19acd07 --- /dev/null +++ b/domain/proof/query/api/src/ProofQueryApi.scala @@ -0,0 +1,16 @@ +package mdr.pdb.proof.query +package api + +import endpoints.Endpoints +import repo.ProofRepository +import fiftyforms.tapir.CustomTapir +import fiftyforms.tapir.InternalServerError + +object ProofQueryApi extends CustomTapir: + + val forUser: ZServerEndpoint[ProofRepository, Any] = + Endpoints.forUser.zServerLogic(osc => + ProofRepository + .matching(ProofRepository.OfPerson(osc)) + .mapError(InternalServerError.fromThrowable) + ) diff --git a/domain/proof/query/endpoints/src/Endpoints.scala b/domain/proof/query/endpoints/src/Endpoints.scala new file mode 100644 index 0000000..e3d9fb9 --- /dev/null +++ b/domain/proof/query/endpoints/src/Endpoints.scala @@ -0,0 +1,21 @@ +package mdr.pdb +package proof +package query.endpoints + +import fiftyforms.tapir.CustomTapir +import mdr.pdb.proof.codecs.Codecs +import fiftyforms.tapir.ServerError +import zio.json.JsonCodec +import zio.json.DeriveJsonCodec + +object Endpoints + extends mdr.pdb.endpoints.Endpoints + with CustomTapir + with Codecs: + + val forUser: Endpoint[Unit, OsobniCislo, ServerError, Seq[Proof], Any] = + endpoint + .in("proof") + .in(path[OsobniCislo]("osobniCislo")) + .out(jsonBody[Seq[Proof]]) + .errorOut(jsonBody[ServerError]) diff --git a/domain/proof/query/repo/src/MongoProofRepository.scala b/domain/proof/query/repo/src/MongoProofRepository.scala index fad055c..cf19e7a 100644 --- a/domain/proof/query/repo/src/MongoProofRepository.scala +++ b/domain/proof/query/repo/src/MongoProofRepository.scala @@ -9,6 +9,7 @@ import org.bson.json.JsonObject import fiftyforms.mongo.MongoJsonRepository +// TODO: extract common mongo repo config, just nest under mongo / case class MongoProofConfig(db: String, collection: String) object MongoProofConfig { @@ -47,7 +48,7 @@ collection: MongoCollection[JsonObject] ) extends ProofRepositoryWrite: import ProofRepository.* - import mdr.pdb.proof.json.Codecs.given + import mdr.pdb.proof.codecs.Codecs.given private val jsonRepo = MongoJsonRepository[Proof, String, Criteria]( collection, { diff --git a/domain/proof/shared/codecs/src/Codecs.scala b/domain/proof/shared/codecs/src/Codecs.scala new file mode 100644 index 0000000..068f37b --- /dev/null +++ b/domain/proof/shared/codecs/src/Codecs.scala @@ -0,0 +1,20 @@ +package mdr.pdb.proof +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends mdr.pdb.codecs.JsonCodecs: + given JsonCodec[Authorization] = DeriveJsonCodec.gen + given JsonCodec[RevocationReason] = DeriveJsonCodec.gen + given JsonCodec[Revocation] = DeriveJsonCodec.gen + given JsonCodec[Proof] = DeriveJsonCodec.gen + +trait TapirCodecs extends mdr.pdb.codecs.TapirCodecs: + given Schema[Authorization] = Schema.derived + given Schema[RevocationReason] = Schema.derived + given Schema[Revocation] = Schema.derived + given Schema[Proof] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/proof/shared/json/src/Codecs.scala b/domain/proof/shared/json/src/Codecs.scala deleted file mode 100644 index 251550e..0000000 --- a/domain/proof/shared/json/src/Codecs.scala +++ /dev/null @@ -1,13 +0,0 @@ -package mdr.pdb.proof -package json - -import zio.json.* - -trait Codecs extends mdr.pdb.json.Codecs: - - given JsonCodec[Authorization] = DeriveJsonCodec.gen - given JsonCodec[RevocationReason] = DeriveJsonCodec.gen - given JsonCodec[Revocation] = DeriveJsonCodec.gen - given JsonCodec[Proof] = DeriveJsonCodec.gen - -object Codecs extends Codecs diff --git a/domain/users/query/codecs/src/Codecs.scala b/domain/users/query/codecs/src/Codecs.scala new file mode 100644 index 0000000..d30f318 --- /dev/null +++ b/domain/users/query/codecs/src/Codecs.scala @@ -0,0 +1,21 @@ +package mdr.pdb +package users.query +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends mdr.pdb.codecs.JsonCodecs: + given JsonCodec[UserContract] = DeriveJsonCodec.gen + given JsonCodec[UserFunction] = DeriveJsonCodec.gen + given JsonCodec[UserInfo] = DeriveJsonCodec.gen + given JsonCodec[UserProfile] = DeriveJsonCodec.gen + +trait TapirCodecs extends mdr.pdb.codecs.TapirCodecs: + given Schema[UserContract] = Schema.derived + given Schema[UserFunction] = Schema.derived + given Schema[UserInfo] = Schema.derived + given Schema[UserProfile] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/users/query/endpoints/src/Endpoints.scala b/domain/users/query/endpoints/src/Endpoints.scala index de0b074..16bd210 100644 --- a/domain/users/query/endpoints/src/Endpoints.scala +++ b/domain/users/query/endpoints/src/Endpoints.scala @@ -3,7 +3,7 @@ import fiftyforms.tapir.CustomTapir import fiftyforms.tapir.ServerError -import mdr.pdb.users.query.json.Codecs +import mdr.pdb.users.query.codecs.Codecs object Endpoints extends mdr.pdb.endpoints.Endpoints diff --git a/build.sbt b/build.sbt index e6f5e40..227e292 100644 --- a/build.sbt +++ b/build.sbt @@ -14,38 +14,38 @@ lazy val proof = entityProject("proof", file("domain/proof")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .repo(_.dependsOn(`mongo-support`)) .entity(_.dependsOn(`akka-persistence-support`)) .projection(_.dependsOn(`akka-persistence-support`)) - .endpoints(_.dependsOn(`tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val parameters = entityProject("parameters", file("domain/parameters")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) - .endpoints(_.dependsOn(`tapir-support`)) + .codecs(_.dependsOn(codecs, `tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val users = entityProject("users", file("domain/users")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val core = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("core")) -lazy val json = crossProject(JSPlatform, JVMPlatform) +lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) - .in(file("json")) + .in(file("codecs")) .settings(IWDeps.zioJson) - .dependsOn(core) + .dependsOn(core, `tapir-support`) lazy val endpoints = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("endpoints")) - .dependsOn(core, json, `tapir-support`) + .dependsOn(core, codecs, `tapir-support`) // TODO: move all from fiftyforms to iterative works lazy val ui = (project in file("fiftyforms/ui")) @@ -80,7 +80,11 @@ lazy val `akka-persistence-support` = project .in(file("fiftyforms/akka-persistence")) - .settings(IWDeps.akka.profiles.eventsourcedJdbcProjection) + .settings( + IWDeps.akka.libs.persistence, + libraryDependencies += "com.typesafe.akka" %% "akka-cluster-sharding-typed" % IWDeps.akka.V, + IWDeps.akka.profiles.eventsourcedJdbcProjection + ) lazy val app = (project in file("app")) .enablePlugins(ScalaJSPlugin, VitePlugin) @@ -166,6 +170,7 @@ proof.query.api, proof.command.api, proof.query.projection, + proof.command.entity, endpoints.jvm ) diff --git a/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala new file mode 100644 index 0000000..22ebfb8 --- /dev/null +++ b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala @@ -0,0 +1,26 @@ +package mdr.pdb +package codecs + +import zio.json.* +import fiftyforms.tapir.CustomTapir + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + given JsonCodec[WhoWhen] = DeriveJsonCodec.gen + given JsonCodec[OsobniCislo] = + JsonCodec.string.transform(OsobniCislo.apply, _.toString) + given JsonFieldEncoder[OsobniCislo] = + JsonFieldEncoder.string.contramap(_.toString) + given JsonFieldDecoder[OsobniCislo] = + JsonFieldDecoder.string.map(OsobniCislo(_)) + +trait TapirCodecs extends CustomTapir: + given Schema[OsobniCislo] = Schema.string + given Codec.PlainCodec[OsobniCislo] = + Codec.string.mapDecode(OsobniCislo.apply andThen DecodeResult.Value.apply)( + _.toString + ) + given Schema[WhoWhen] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/parameters/shared/codecs/src/Codecs.scala b/domain/parameters/shared/codecs/src/Codecs.scala new file mode 100644 index 0000000..751e79b --- /dev/null +++ b/domain/parameters/shared/codecs/src/Codecs.scala @@ -0,0 +1,10 @@ +package mdr.pdb +package parameters +package codecs + +import zio.json.* + +trait Codecs: + + given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen + given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/parameters/shared/json/src/Codecs.scala b/domain/parameters/shared/json/src/Codecs.scala deleted file mode 100644 index b849072..0000000 --- a/domain/parameters/shared/json/src/Codecs.scala +++ /dev/null @@ -1,10 +0,0 @@ -package mdr.pdb -package parameters -package json - -import zio.json.* - -trait Codecs: - - given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen - given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/proof/command/api/src/ProofCommandApi.scala b/domain/proof/command/api/src/ProofCommandApi.scala new file mode 100644 index 0000000..12f747e --- /dev/null +++ b/domain/proof/command/api/src/ProofCommandApi.scala @@ -0,0 +1,16 @@ +package mdr.pdb.proof.command +package api + +import endpoints.Endpoints +import entity.ProofCommandBus +import fiftyforms.tapir.CustomTapir +import fiftyforms.tapir.InternalServerError + +object ProofCommandApi extends CustomTapir: + + val submitCommand: ZServerEndpoint[ProofCommandBus, Any] = + Endpoints.submitCommand.zServerLogic(cmd => + ProofCommandBus + .submitCommand(cmd) + .mapError(InternalServerError.fromThrowable) + ) diff --git a/domain/proof/command/codecs/src/Codecs.scala b/domain/proof/command/codecs/src/Codecs.scala new file mode 100644 index 0000000..0e75f82 --- /dev/null +++ b/domain/proof/command/codecs/src/Codecs.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof.command +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends proof.codecs.JsonCodecs: + + given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen + given JsonCodec[CreateProof] = DeriveJsonCodec.gen + given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen + given JsonCodec[UpdateProof] = DeriveJsonCodec.gen + given JsonCodec[RevokeProof] = DeriveJsonCodec.gen + given JsonCodec[Command] = DeriveJsonCodec.gen + +trait TapirCodecs extends proof.codecs.TapirCodecs: + + given Schema[AuthorizeOption] = Schema.derived + given Schema[CreateProof] = Schema.derived + given Schema[AuthorizeProof] = Schema.derived + given Schema[UpdateProof] = Schema.derived + given Schema[RevokeProof] = Schema.derived + given Schema[Command] = Schema.derived diff --git a/domain/proof/command/endpoints/src/Endpoints.scala b/domain/proof/command/endpoints/src/Endpoints.scala new file mode 100644 index 0000000..dcd114e --- /dev/null +++ b/domain/proof/command/endpoints/src/Endpoints.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof +package command +package endpoints + +import fiftyforms.tapir.CustomTapir +import mdr.pdb.proof.command.codecs.Codecs +import fiftyforms.tapir.ServerError +import zio.json.JsonCodec +import zio.json.DeriveJsonCodec +import sttp.model.StatusCode + +object Endpoints + extends mdr.pdb.endpoints.Endpoints + with CustomTapir + with Codecs: + + val submitCommand: Endpoint[Unit, Command, ServerError, Unit, Any] = + endpoint + .in("command") + .in("proof") + .post + .in(jsonBody[Command]) + .out(statusCode(StatusCode.Accepted)) + .errorOut(jsonBody[ServerError]) diff --git a/domain/proof/command/entity/src/ProofBehavior.scala b/domain/proof/command/entity/src/ProofBehavior.scala new file mode 100644 index 0000000..f679981 --- /dev/null +++ b/domain/proof/command/entity/src/ProofBehavior.scala @@ -0,0 +1,120 @@ +package mdr.pdb +package proof +package command +package entity + +import akka.persistence.typed.scaladsl.Effect +import akka.persistence.typed.scaladsl.EventSourcedBehavior +import akka.persistence.typed.PersistenceId +import akka.actor.typed.Behavior +import akka.actor.typed.ActorRef +import akka.pattern.StatusReply +import akka.Done + +import fiftyforms.akka.* +import akka.actor.typed.ActorSystem +import akka.cluster.sharding.typed.scaladsl.EntityTypeKey +import akka.cluster.sharding.typed.scaladsl.EntityContext +import akka.cluster.sharding.typed.scaladsl.ClusterSharding +import akka.cluster.sharding.typed.scaladsl.Entity + +object ProofBehavior: + + type ReplyTo = ActorRef[StatusReply[Done]] + + case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) + + type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] + + type ProofReplyEffect = + akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] + + val EntityKey: EntityTypeKey[ProofCommand] = EntityTypeKey("Proof") + + def init(system: ActorSystem[_]): Unit = + val behaviorFactory: EntityContext[ProofCommand] => Behavior[ProofCommand] = + entityContext => ProofBehavior(entityContext.entityId) + ClusterSharding(system).init(Entity(EntityKey)(behaviorFactory)) + + def apply(persistenceId: String): Behavior[ProofCommand] = + import ProofEventHandler.* + EventSourcedBehavior + .withEnforcedReplies[ProofCommand, ProofEvent, State]( + persistenceId = PersistenceId.ofUniqueId(persistenceId), + emptyState = None, + commandHandler = handleProofCommand, + eventHandler = (state, event) => + state.handleEvent(event).orElse(unhandledEvent(event, state)) + ) + .withTagger(_ => Set(ProofEvent.Tag)) + + def handleProofCommand( + state: State, + command: ProofCommand + ): ProofReplyEffect = + handleCommand(state, command.command) match + case Some(events) => persist(events, command.meta, command.replyTo) + case _ => unhandled(command.command, command.replyTo) + + def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = + state match + case None => + cmd match + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + Authorized(note) + ) => + Some( + Seq( + ProofCreated(id, person, parameterId, criterionId, documents), + ProofAuthorized(id, note) + ) + ) + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + _ + ) => + Some( + Seq(ProofCreated(id, person, parameterId, criterionId, documents)) + ) + case _ => None + case Some(proof) if proof.isRevoked => None + case Some(proof) if proof.isAuthorized => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + case Some(proof) => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case UpdateProof(id, documents) => + Some(Seq(ProofUpdated(id, documents))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + + private def persist( + events: Seq[Event], + meta: WW, + replyTo: ReplyTo + ): ProofReplyEffect = + Effect + .persist(events.map(ProofEvent(_, meta))) + .thenReply(replyTo)(_ => StatusReply.Ack) + + private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = + Effect.unhandled.thenReply(replyTo)(s => + StatusReply.error(CommandNotAvailable(command, s)) + ) + + private def unhandledEvent(event: ProofEvent, state: State): Nothing = + throw UnhandledEvent(event, state) diff --git a/domain/proof/command/entity/src/ProofBehaviour.scala b/domain/proof/command/entity/src/ProofBehaviour.scala deleted file mode 100644 index e8c7e4e..0000000 --- a/domain/proof/command/entity/src/ProofBehaviour.scala +++ /dev/null @@ -1,108 +0,0 @@ -package mdr.pdb -package proof -package command -package entity - -import akka.persistence.typed.scaladsl.EventSourcedBehavior -import akka.persistence.typed.PersistenceId -import akka.actor.typed.Behavior -import akka.actor.typed.ActorRef -import akka.pattern.StatusReply -import akka.Done - -import fiftyforms.akka.* -import akka.persistence.typed.scaladsl.Effect - -object ProofBehaviour: - - type ReplyTo = ActorRef[StatusReply[Done]] - - case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) - - type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] - - type ProofReplyEffect = - akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] - - def apply(persistenceId: PersistenceId): Behavior[ProofCommand] = - import ProofEventHandler.* - EventSourcedBehavior - .withEnforcedReplies[ProofCommand, ProofEvent, State]( - persistenceId = persistenceId, - emptyState = None, - commandHandler = handleProofCommand, - eventHandler = (state, event) => - state.handleEvent(event).orElse(unhandledEvent(event, state)) - ) - .withTagger(_ => Set(ProofEvent.Tag)) - - def handleProofCommand( - state: State, - command: ProofCommand - ): ProofReplyEffect = - handleCommand(state, command.command) match - case Some(events) => persist(events, command.meta, command.replyTo) - case _ => unhandled(command.command, command.replyTo) - - def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = - state match - case None => - cmd match - case CreateProof( - id, - person, - parameterId, - criterionId, - documents, - Authorized(note) - ) => - Some( - Seq( - ProofCreated(id, person, parameterId, criterionId, documents), - ProofAuthorized(id, note) - ) - ) - case CreateProof( - id, - person, - parameterId, - criterionId, - documents, - _ - ) => - Some( - Seq(ProofCreated(id, person, parameterId, criterionId, documents)) - ) - case _ => None - case Some(proof) if proof.isRevoked => None - case Some(proof) if proof.isAuthorized => - cmd match - case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) - case RevokeProof(id, reason, since, documents) => - Some(Seq((ProofRevoked(id, reason, since, documents)))) - case _ => None - case Some(proof) => - cmd match - case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) - case UpdateProof(id, documents) => - Some(Seq(ProofUpdated(id, documents))) - case RevokeProof(id, reason, since, documents) => - Some(Seq((ProofRevoked(id, reason, since, documents)))) - case _ => None - - private def persist( - events: Seq[Event], - meta: WW, - replyTo: ReplyTo - ): ProofReplyEffect = - Effect - .persist(events.map(ProofEvent(_, meta))) - .thenReply(replyTo)(_ => StatusReply.Ack) - - private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = - Effect.unhandled.thenReply(replyTo)(s => - StatusReply.error(CommandNotAvailable(command, s)) - ) - - private def unhandledEvent(event: ProofEvent, state: State): Nothing = - throw UnhandledEvent(event, state) diff --git a/domain/proof/command/entity/src/ProofCommandBus.scala b/domain/proof/command/entity/src/ProofCommandBus.scala new file mode 100644 index 0000000..3167e02 --- /dev/null +++ b/domain/proof/command/entity/src/ProofCommandBus.scala @@ -0,0 +1,50 @@ +package mdr.pdb +package proof +package command +package entity + +import zio.* +import akka.actor.typed.ActorSystem +import akka.cluster.sharding.typed.scaladsl.EntityContext +import akka.actor.typed.Behavior +import akka.cluster.sharding.typed.scaladsl.ClusterSharding +import akka.util.Timeout +import java.time.Instant + +object ProofCommandBus: + def submitCommand(command: Command): RIO[ProofCommandBus, Unit] = + ZIO.serviceWithZIO(_.submitCommand(command)) + + val layer: RLayer[ActorSystem[_], ProofCommandBus] = + ZIO + .serviceWithZIO[ActorSystem[_]](system => + for + timeout <- Task.attempt( + Timeout.create( + system.settings.config.getDuration("proof-bus.timeout") + ) + ) + // TODO: init only once + _ <- Task.attempt(ProofBehavior.init(system)) + yield ProofCommandBus(system)(using timeout) + ) + .toLayer + +class ProofCommandBus(system: ActorSystem[_])(using timeout: Timeout): + private val sharding = ClusterSharding(system) + + def submitCommand(command: Command): Task[Unit] = + for + entityRef <- Task.attempt( + sharding.entityRefFor(ProofBehavior.EntityKey, command.id) + ) + reply <- ZIO.fromFuture(_ => + entityRef.askWithStatus( + ProofBehavior.ProofCommand( + command, + WhoWhen(OsobniCislo("0123"), Instant.now()), + _ + ) + ) + ) + yield () diff --git a/domain/proof/command/json/src/Codecs.scala b/domain/proof/command/json/src/Codecs.scala deleted file mode 100644 index 6867615..0000000 --- a/domain/proof/command/json/src/Codecs.scala +++ /dev/null @@ -1,13 +0,0 @@ -package mdr.pdb -package proof.command -package json - -import zio.json.* - -trait Codecs extends proof.json.Codecs: - - given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen - given JsonCodec[CreateProof] = DeriveJsonCodec.gen - given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen - given JsonCodec[UpdateProof] = DeriveJsonCodec.gen - given JsonCodec[RevokeProof] = DeriveJsonCodec.gen diff --git a/domain/proof/command/model/src/Command.scala b/domain/proof/command/model/src/Command.scala index 35a15af..1bff3cd 100644 --- a/domain/proof/command/model/src/Command.scala +++ b/domain/proof/command/model/src/Command.scala @@ -5,7 +5,8 @@ import java.time.LocalDate import java.time.Instant -sealed trait Command +sealed trait Command: + def id: Proof.Id sealed trait AuthorizeOption case object Unauthorized extends AuthorizeOption diff --git a/domain/proof/query/api/src/ProofQueryApi.scala b/domain/proof/query/api/src/ProofQueryApi.scala new file mode 100644 index 0000000..19acd07 --- /dev/null +++ b/domain/proof/query/api/src/ProofQueryApi.scala @@ -0,0 +1,16 @@ +package mdr.pdb.proof.query +package api + +import endpoints.Endpoints +import repo.ProofRepository +import fiftyforms.tapir.CustomTapir +import fiftyforms.tapir.InternalServerError + +object ProofQueryApi extends CustomTapir: + + val forUser: ZServerEndpoint[ProofRepository, Any] = + Endpoints.forUser.zServerLogic(osc => + ProofRepository + .matching(ProofRepository.OfPerson(osc)) + .mapError(InternalServerError.fromThrowable) + ) diff --git a/domain/proof/query/endpoints/src/Endpoints.scala b/domain/proof/query/endpoints/src/Endpoints.scala new file mode 100644 index 0000000..e3d9fb9 --- /dev/null +++ b/domain/proof/query/endpoints/src/Endpoints.scala @@ -0,0 +1,21 @@ +package mdr.pdb +package proof +package query.endpoints + +import fiftyforms.tapir.CustomTapir +import mdr.pdb.proof.codecs.Codecs +import fiftyforms.tapir.ServerError +import zio.json.JsonCodec +import zio.json.DeriveJsonCodec + +object Endpoints + extends mdr.pdb.endpoints.Endpoints + with CustomTapir + with Codecs: + + val forUser: Endpoint[Unit, OsobniCislo, ServerError, Seq[Proof], Any] = + endpoint + .in("proof") + .in(path[OsobniCislo]("osobniCislo")) + .out(jsonBody[Seq[Proof]]) + .errorOut(jsonBody[ServerError]) diff --git a/domain/proof/query/repo/src/MongoProofRepository.scala b/domain/proof/query/repo/src/MongoProofRepository.scala index fad055c..cf19e7a 100644 --- a/domain/proof/query/repo/src/MongoProofRepository.scala +++ b/domain/proof/query/repo/src/MongoProofRepository.scala @@ -9,6 +9,7 @@ import org.bson.json.JsonObject import fiftyforms.mongo.MongoJsonRepository +// TODO: extract common mongo repo config, just nest under mongo / case class MongoProofConfig(db: String, collection: String) object MongoProofConfig { @@ -47,7 +48,7 @@ collection: MongoCollection[JsonObject] ) extends ProofRepositoryWrite: import ProofRepository.* - import mdr.pdb.proof.json.Codecs.given + import mdr.pdb.proof.codecs.Codecs.given private val jsonRepo = MongoJsonRepository[Proof, String, Criteria]( collection, { diff --git a/domain/proof/shared/codecs/src/Codecs.scala b/domain/proof/shared/codecs/src/Codecs.scala new file mode 100644 index 0000000..068f37b --- /dev/null +++ b/domain/proof/shared/codecs/src/Codecs.scala @@ -0,0 +1,20 @@ +package mdr.pdb.proof +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends mdr.pdb.codecs.JsonCodecs: + given JsonCodec[Authorization] = DeriveJsonCodec.gen + given JsonCodec[RevocationReason] = DeriveJsonCodec.gen + given JsonCodec[Revocation] = DeriveJsonCodec.gen + given JsonCodec[Proof] = DeriveJsonCodec.gen + +trait TapirCodecs extends mdr.pdb.codecs.TapirCodecs: + given Schema[Authorization] = Schema.derived + given Schema[RevocationReason] = Schema.derived + given Schema[Revocation] = Schema.derived + given Schema[Proof] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/proof/shared/json/src/Codecs.scala b/domain/proof/shared/json/src/Codecs.scala deleted file mode 100644 index 251550e..0000000 --- a/domain/proof/shared/json/src/Codecs.scala +++ /dev/null @@ -1,13 +0,0 @@ -package mdr.pdb.proof -package json - -import zio.json.* - -trait Codecs extends mdr.pdb.json.Codecs: - - given JsonCodec[Authorization] = DeriveJsonCodec.gen - given JsonCodec[RevocationReason] = DeriveJsonCodec.gen - given JsonCodec[Revocation] = DeriveJsonCodec.gen - given JsonCodec[Proof] = DeriveJsonCodec.gen - -object Codecs extends Codecs diff --git a/domain/users/query/codecs/src/Codecs.scala b/domain/users/query/codecs/src/Codecs.scala new file mode 100644 index 0000000..d30f318 --- /dev/null +++ b/domain/users/query/codecs/src/Codecs.scala @@ -0,0 +1,21 @@ +package mdr.pdb +package users.query +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends mdr.pdb.codecs.JsonCodecs: + given JsonCodec[UserContract] = DeriveJsonCodec.gen + given JsonCodec[UserFunction] = DeriveJsonCodec.gen + given JsonCodec[UserInfo] = DeriveJsonCodec.gen + given JsonCodec[UserProfile] = DeriveJsonCodec.gen + +trait TapirCodecs extends mdr.pdb.codecs.TapirCodecs: + given Schema[UserContract] = Schema.derived + given Schema[UserFunction] = Schema.derived + given Schema[UserInfo] = Schema.derived + given Schema[UserProfile] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/users/query/endpoints/src/Endpoints.scala b/domain/users/query/endpoints/src/Endpoints.scala index de0b074..16bd210 100644 --- a/domain/users/query/endpoints/src/Endpoints.scala +++ b/domain/users/query/endpoints/src/Endpoints.scala @@ -3,7 +3,7 @@ import fiftyforms.tapir.CustomTapir import fiftyforms.tapir.ServerError -import mdr.pdb.users.query.json.Codecs +import mdr.pdb.users.query.codecs.Codecs object Endpoints extends mdr.pdb.endpoints.Endpoints diff --git a/domain/users/query/json/src/Codecs.scala b/domain/users/query/json/src/Codecs.scala deleted file mode 100644 index df7edd1..0000000 --- a/domain/users/query/json/src/Codecs.scala +++ /dev/null @@ -1,14 +0,0 @@ -package mdr.pdb -package users.query -package json - -import zio.json.* - -trait Codecs extends mdr.pdb.json.Codecs: - - given JsonCodec[UserContract] = DeriveJsonCodec.gen - given JsonCodec[UserFunction] = DeriveJsonCodec.gen - given JsonCodec[UserInfo] = DeriveJsonCodec.gen - given JsonCodec[UserProfile] = DeriveJsonCodec.gen - -object Codecs extends Codecs diff --git a/build.sbt b/build.sbt index e6f5e40..227e292 100644 --- a/build.sbt +++ b/build.sbt @@ -14,38 +14,38 @@ lazy val proof = entityProject("proof", file("domain/proof")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .repo(_.dependsOn(`mongo-support`)) .entity(_.dependsOn(`akka-persistence-support`)) .projection(_.dependsOn(`akka-persistence-support`)) - .endpoints(_.dependsOn(`tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val parameters = entityProject("parameters", file("domain/parameters")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) - .endpoints(_.dependsOn(`tapir-support`)) + .codecs(_.dependsOn(codecs, `tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val users = entityProject("users", file("domain/users")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val core = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("core")) -lazy val json = crossProject(JSPlatform, JVMPlatform) +lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) - .in(file("json")) + .in(file("codecs")) .settings(IWDeps.zioJson) - .dependsOn(core) + .dependsOn(core, `tapir-support`) lazy val endpoints = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("endpoints")) - .dependsOn(core, json, `tapir-support`) + .dependsOn(core, codecs, `tapir-support`) // TODO: move all from fiftyforms to iterative works lazy val ui = (project in file("fiftyforms/ui")) @@ -80,7 +80,11 @@ lazy val `akka-persistence-support` = project .in(file("fiftyforms/akka-persistence")) - .settings(IWDeps.akka.profiles.eventsourcedJdbcProjection) + .settings( + IWDeps.akka.libs.persistence, + libraryDependencies += "com.typesafe.akka" %% "akka-cluster-sharding-typed" % IWDeps.akka.V, + IWDeps.akka.profiles.eventsourcedJdbcProjection + ) lazy val app = (project in file("app")) .enablePlugins(ScalaJSPlugin, VitePlugin) @@ -166,6 +170,7 @@ proof.query.api, proof.command.api, proof.query.projection, + proof.command.entity, endpoints.jvm ) diff --git a/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala new file mode 100644 index 0000000..22ebfb8 --- /dev/null +++ b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala @@ -0,0 +1,26 @@ +package mdr.pdb +package codecs + +import zio.json.* +import fiftyforms.tapir.CustomTapir + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + given JsonCodec[WhoWhen] = DeriveJsonCodec.gen + given JsonCodec[OsobniCislo] = + JsonCodec.string.transform(OsobniCislo.apply, _.toString) + given JsonFieldEncoder[OsobniCislo] = + JsonFieldEncoder.string.contramap(_.toString) + given JsonFieldDecoder[OsobniCislo] = + JsonFieldDecoder.string.map(OsobniCislo(_)) + +trait TapirCodecs extends CustomTapir: + given Schema[OsobniCislo] = Schema.string + given Codec.PlainCodec[OsobniCislo] = + Codec.string.mapDecode(OsobniCislo.apply andThen DecodeResult.Value.apply)( + _.toString + ) + given Schema[WhoWhen] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/parameters/shared/codecs/src/Codecs.scala b/domain/parameters/shared/codecs/src/Codecs.scala new file mode 100644 index 0000000..751e79b --- /dev/null +++ b/domain/parameters/shared/codecs/src/Codecs.scala @@ -0,0 +1,10 @@ +package mdr.pdb +package parameters +package codecs + +import zio.json.* + +trait Codecs: + + given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen + given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/parameters/shared/json/src/Codecs.scala b/domain/parameters/shared/json/src/Codecs.scala deleted file mode 100644 index b849072..0000000 --- a/domain/parameters/shared/json/src/Codecs.scala +++ /dev/null @@ -1,10 +0,0 @@ -package mdr.pdb -package parameters -package json - -import zio.json.* - -trait Codecs: - - given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen - given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/proof/command/api/src/ProofCommandApi.scala b/domain/proof/command/api/src/ProofCommandApi.scala new file mode 100644 index 0000000..12f747e --- /dev/null +++ b/domain/proof/command/api/src/ProofCommandApi.scala @@ -0,0 +1,16 @@ +package mdr.pdb.proof.command +package api + +import endpoints.Endpoints +import entity.ProofCommandBus +import fiftyforms.tapir.CustomTapir +import fiftyforms.tapir.InternalServerError + +object ProofCommandApi extends CustomTapir: + + val submitCommand: ZServerEndpoint[ProofCommandBus, Any] = + Endpoints.submitCommand.zServerLogic(cmd => + ProofCommandBus + .submitCommand(cmd) + .mapError(InternalServerError.fromThrowable) + ) diff --git a/domain/proof/command/codecs/src/Codecs.scala b/domain/proof/command/codecs/src/Codecs.scala new file mode 100644 index 0000000..0e75f82 --- /dev/null +++ b/domain/proof/command/codecs/src/Codecs.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof.command +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends proof.codecs.JsonCodecs: + + given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen + given JsonCodec[CreateProof] = DeriveJsonCodec.gen + given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen + given JsonCodec[UpdateProof] = DeriveJsonCodec.gen + given JsonCodec[RevokeProof] = DeriveJsonCodec.gen + given JsonCodec[Command] = DeriveJsonCodec.gen + +trait TapirCodecs extends proof.codecs.TapirCodecs: + + given Schema[AuthorizeOption] = Schema.derived + given Schema[CreateProof] = Schema.derived + given Schema[AuthorizeProof] = Schema.derived + given Schema[UpdateProof] = Schema.derived + given Schema[RevokeProof] = Schema.derived + given Schema[Command] = Schema.derived diff --git a/domain/proof/command/endpoints/src/Endpoints.scala b/domain/proof/command/endpoints/src/Endpoints.scala new file mode 100644 index 0000000..dcd114e --- /dev/null +++ b/domain/proof/command/endpoints/src/Endpoints.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof +package command +package endpoints + +import fiftyforms.tapir.CustomTapir +import mdr.pdb.proof.command.codecs.Codecs +import fiftyforms.tapir.ServerError +import zio.json.JsonCodec +import zio.json.DeriveJsonCodec +import sttp.model.StatusCode + +object Endpoints + extends mdr.pdb.endpoints.Endpoints + with CustomTapir + with Codecs: + + val submitCommand: Endpoint[Unit, Command, ServerError, Unit, Any] = + endpoint + .in("command") + .in("proof") + .post + .in(jsonBody[Command]) + .out(statusCode(StatusCode.Accepted)) + .errorOut(jsonBody[ServerError]) diff --git a/domain/proof/command/entity/src/ProofBehavior.scala b/domain/proof/command/entity/src/ProofBehavior.scala new file mode 100644 index 0000000..f679981 --- /dev/null +++ b/domain/proof/command/entity/src/ProofBehavior.scala @@ -0,0 +1,120 @@ +package mdr.pdb +package proof +package command +package entity + +import akka.persistence.typed.scaladsl.Effect +import akka.persistence.typed.scaladsl.EventSourcedBehavior +import akka.persistence.typed.PersistenceId +import akka.actor.typed.Behavior +import akka.actor.typed.ActorRef +import akka.pattern.StatusReply +import akka.Done + +import fiftyforms.akka.* +import akka.actor.typed.ActorSystem +import akka.cluster.sharding.typed.scaladsl.EntityTypeKey +import akka.cluster.sharding.typed.scaladsl.EntityContext +import akka.cluster.sharding.typed.scaladsl.ClusterSharding +import akka.cluster.sharding.typed.scaladsl.Entity + +object ProofBehavior: + + type ReplyTo = ActorRef[StatusReply[Done]] + + case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) + + type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] + + type ProofReplyEffect = + akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] + + val EntityKey: EntityTypeKey[ProofCommand] = EntityTypeKey("Proof") + + def init(system: ActorSystem[_]): Unit = + val behaviorFactory: EntityContext[ProofCommand] => Behavior[ProofCommand] = + entityContext => ProofBehavior(entityContext.entityId) + ClusterSharding(system).init(Entity(EntityKey)(behaviorFactory)) + + def apply(persistenceId: String): Behavior[ProofCommand] = + import ProofEventHandler.* + EventSourcedBehavior + .withEnforcedReplies[ProofCommand, ProofEvent, State]( + persistenceId = PersistenceId.ofUniqueId(persistenceId), + emptyState = None, + commandHandler = handleProofCommand, + eventHandler = (state, event) => + state.handleEvent(event).orElse(unhandledEvent(event, state)) + ) + .withTagger(_ => Set(ProofEvent.Tag)) + + def handleProofCommand( + state: State, + command: ProofCommand + ): ProofReplyEffect = + handleCommand(state, command.command) match + case Some(events) => persist(events, command.meta, command.replyTo) + case _ => unhandled(command.command, command.replyTo) + + def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = + state match + case None => + cmd match + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + Authorized(note) + ) => + Some( + Seq( + ProofCreated(id, person, parameterId, criterionId, documents), + ProofAuthorized(id, note) + ) + ) + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + _ + ) => + Some( + Seq(ProofCreated(id, person, parameterId, criterionId, documents)) + ) + case _ => None + case Some(proof) if proof.isRevoked => None + case Some(proof) if proof.isAuthorized => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + case Some(proof) => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case UpdateProof(id, documents) => + Some(Seq(ProofUpdated(id, documents))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + + private def persist( + events: Seq[Event], + meta: WW, + replyTo: ReplyTo + ): ProofReplyEffect = + Effect + .persist(events.map(ProofEvent(_, meta))) + .thenReply(replyTo)(_ => StatusReply.Ack) + + private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = + Effect.unhandled.thenReply(replyTo)(s => + StatusReply.error(CommandNotAvailable(command, s)) + ) + + private def unhandledEvent(event: ProofEvent, state: State): Nothing = + throw UnhandledEvent(event, state) diff --git a/domain/proof/command/entity/src/ProofBehaviour.scala b/domain/proof/command/entity/src/ProofBehaviour.scala deleted file mode 100644 index e8c7e4e..0000000 --- a/domain/proof/command/entity/src/ProofBehaviour.scala +++ /dev/null @@ -1,108 +0,0 @@ -package mdr.pdb -package proof -package command -package entity - -import akka.persistence.typed.scaladsl.EventSourcedBehavior -import akka.persistence.typed.PersistenceId -import akka.actor.typed.Behavior -import akka.actor.typed.ActorRef -import akka.pattern.StatusReply -import akka.Done - -import fiftyforms.akka.* -import akka.persistence.typed.scaladsl.Effect - -object ProofBehaviour: - - type ReplyTo = ActorRef[StatusReply[Done]] - - case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) - - type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] - - type ProofReplyEffect = - akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] - - def apply(persistenceId: PersistenceId): Behavior[ProofCommand] = - import ProofEventHandler.* - EventSourcedBehavior - .withEnforcedReplies[ProofCommand, ProofEvent, State]( - persistenceId = persistenceId, - emptyState = None, - commandHandler = handleProofCommand, - eventHandler = (state, event) => - state.handleEvent(event).orElse(unhandledEvent(event, state)) - ) - .withTagger(_ => Set(ProofEvent.Tag)) - - def handleProofCommand( - state: State, - command: ProofCommand - ): ProofReplyEffect = - handleCommand(state, command.command) match - case Some(events) => persist(events, command.meta, command.replyTo) - case _ => unhandled(command.command, command.replyTo) - - def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = - state match - case None => - cmd match - case CreateProof( - id, - person, - parameterId, - criterionId, - documents, - Authorized(note) - ) => - Some( - Seq( - ProofCreated(id, person, parameterId, criterionId, documents), - ProofAuthorized(id, note) - ) - ) - case CreateProof( - id, - person, - parameterId, - criterionId, - documents, - _ - ) => - Some( - Seq(ProofCreated(id, person, parameterId, criterionId, documents)) - ) - case _ => None - case Some(proof) if proof.isRevoked => None - case Some(proof) if proof.isAuthorized => - cmd match - case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) - case RevokeProof(id, reason, since, documents) => - Some(Seq((ProofRevoked(id, reason, since, documents)))) - case _ => None - case Some(proof) => - cmd match - case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) - case UpdateProof(id, documents) => - Some(Seq(ProofUpdated(id, documents))) - case RevokeProof(id, reason, since, documents) => - Some(Seq((ProofRevoked(id, reason, since, documents)))) - case _ => None - - private def persist( - events: Seq[Event], - meta: WW, - replyTo: ReplyTo - ): ProofReplyEffect = - Effect - .persist(events.map(ProofEvent(_, meta))) - .thenReply(replyTo)(_ => StatusReply.Ack) - - private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = - Effect.unhandled.thenReply(replyTo)(s => - StatusReply.error(CommandNotAvailable(command, s)) - ) - - private def unhandledEvent(event: ProofEvent, state: State): Nothing = - throw UnhandledEvent(event, state) diff --git a/domain/proof/command/entity/src/ProofCommandBus.scala b/domain/proof/command/entity/src/ProofCommandBus.scala new file mode 100644 index 0000000..3167e02 --- /dev/null +++ b/domain/proof/command/entity/src/ProofCommandBus.scala @@ -0,0 +1,50 @@ +package mdr.pdb +package proof +package command +package entity + +import zio.* +import akka.actor.typed.ActorSystem +import akka.cluster.sharding.typed.scaladsl.EntityContext +import akka.actor.typed.Behavior +import akka.cluster.sharding.typed.scaladsl.ClusterSharding +import akka.util.Timeout +import java.time.Instant + +object ProofCommandBus: + def submitCommand(command: Command): RIO[ProofCommandBus, Unit] = + ZIO.serviceWithZIO(_.submitCommand(command)) + + val layer: RLayer[ActorSystem[_], ProofCommandBus] = + ZIO + .serviceWithZIO[ActorSystem[_]](system => + for + timeout <- Task.attempt( + Timeout.create( + system.settings.config.getDuration("proof-bus.timeout") + ) + ) + // TODO: init only once + _ <- Task.attempt(ProofBehavior.init(system)) + yield ProofCommandBus(system)(using timeout) + ) + .toLayer + +class ProofCommandBus(system: ActorSystem[_])(using timeout: Timeout): + private val sharding = ClusterSharding(system) + + def submitCommand(command: Command): Task[Unit] = + for + entityRef <- Task.attempt( + sharding.entityRefFor(ProofBehavior.EntityKey, command.id) + ) + reply <- ZIO.fromFuture(_ => + entityRef.askWithStatus( + ProofBehavior.ProofCommand( + command, + WhoWhen(OsobniCislo("0123"), Instant.now()), + _ + ) + ) + ) + yield () diff --git a/domain/proof/command/json/src/Codecs.scala b/domain/proof/command/json/src/Codecs.scala deleted file mode 100644 index 6867615..0000000 --- a/domain/proof/command/json/src/Codecs.scala +++ /dev/null @@ -1,13 +0,0 @@ -package mdr.pdb -package proof.command -package json - -import zio.json.* - -trait Codecs extends proof.json.Codecs: - - given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen - given JsonCodec[CreateProof] = DeriveJsonCodec.gen - given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen - given JsonCodec[UpdateProof] = DeriveJsonCodec.gen - given JsonCodec[RevokeProof] = DeriveJsonCodec.gen diff --git a/domain/proof/command/model/src/Command.scala b/domain/proof/command/model/src/Command.scala index 35a15af..1bff3cd 100644 --- a/domain/proof/command/model/src/Command.scala +++ b/domain/proof/command/model/src/Command.scala @@ -5,7 +5,8 @@ import java.time.LocalDate import java.time.Instant -sealed trait Command +sealed trait Command: + def id: Proof.Id sealed trait AuthorizeOption case object Unauthorized extends AuthorizeOption diff --git a/domain/proof/query/api/src/ProofQueryApi.scala b/domain/proof/query/api/src/ProofQueryApi.scala new file mode 100644 index 0000000..19acd07 --- /dev/null +++ b/domain/proof/query/api/src/ProofQueryApi.scala @@ -0,0 +1,16 @@ +package mdr.pdb.proof.query +package api + +import endpoints.Endpoints +import repo.ProofRepository +import fiftyforms.tapir.CustomTapir +import fiftyforms.tapir.InternalServerError + +object ProofQueryApi extends CustomTapir: + + val forUser: ZServerEndpoint[ProofRepository, Any] = + Endpoints.forUser.zServerLogic(osc => + ProofRepository + .matching(ProofRepository.OfPerson(osc)) + .mapError(InternalServerError.fromThrowable) + ) diff --git a/domain/proof/query/endpoints/src/Endpoints.scala b/domain/proof/query/endpoints/src/Endpoints.scala new file mode 100644 index 0000000..e3d9fb9 --- /dev/null +++ b/domain/proof/query/endpoints/src/Endpoints.scala @@ -0,0 +1,21 @@ +package mdr.pdb +package proof +package query.endpoints + +import fiftyforms.tapir.CustomTapir +import mdr.pdb.proof.codecs.Codecs +import fiftyforms.tapir.ServerError +import zio.json.JsonCodec +import zio.json.DeriveJsonCodec + +object Endpoints + extends mdr.pdb.endpoints.Endpoints + with CustomTapir + with Codecs: + + val forUser: Endpoint[Unit, OsobniCislo, ServerError, Seq[Proof], Any] = + endpoint + .in("proof") + .in(path[OsobniCislo]("osobniCislo")) + .out(jsonBody[Seq[Proof]]) + .errorOut(jsonBody[ServerError]) diff --git a/domain/proof/query/repo/src/MongoProofRepository.scala b/domain/proof/query/repo/src/MongoProofRepository.scala index fad055c..cf19e7a 100644 --- a/domain/proof/query/repo/src/MongoProofRepository.scala +++ b/domain/proof/query/repo/src/MongoProofRepository.scala @@ -9,6 +9,7 @@ import org.bson.json.JsonObject import fiftyforms.mongo.MongoJsonRepository +// TODO: extract common mongo repo config, just nest under mongo / case class MongoProofConfig(db: String, collection: String) object MongoProofConfig { @@ -47,7 +48,7 @@ collection: MongoCollection[JsonObject] ) extends ProofRepositoryWrite: import ProofRepository.* - import mdr.pdb.proof.json.Codecs.given + import mdr.pdb.proof.codecs.Codecs.given private val jsonRepo = MongoJsonRepository[Proof, String, Criteria]( collection, { diff --git a/domain/proof/shared/codecs/src/Codecs.scala b/domain/proof/shared/codecs/src/Codecs.scala new file mode 100644 index 0000000..068f37b --- /dev/null +++ b/domain/proof/shared/codecs/src/Codecs.scala @@ -0,0 +1,20 @@ +package mdr.pdb.proof +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends mdr.pdb.codecs.JsonCodecs: + given JsonCodec[Authorization] = DeriveJsonCodec.gen + given JsonCodec[RevocationReason] = DeriveJsonCodec.gen + given JsonCodec[Revocation] = DeriveJsonCodec.gen + given JsonCodec[Proof] = DeriveJsonCodec.gen + +trait TapirCodecs extends mdr.pdb.codecs.TapirCodecs: + given Schema[Authorization] = Schema.derived + given Schema[RevocationReason] = Schema.derived + given Schema[Revocation] = Schema.derived + given Schema[Proof] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/proof/shared/json/src/Codecs.scala b/domain/proof/shared/json/src/Codecs.scala deleted file mode 100644 index 251550e..0000000 --- a/domain/proof/shared/json/src/Codecs.scala +++ /dev/null @@ -1,13 +0,0 @@ -package mdr.pdb.proof -package json - -import zio.json.* - -trait Codecs extends mdr.pdb.json.Codecs: - - given JsonCodec[Authorization] = DeriveJsonCodec.gen - given JsonCodec[RevocationReason] = DeriveJsonCodec.gen - given JsonCodec[Revocation] = DeriveJsonCodec.gen - given JsonCodec[Proof] = DeriveJsonCodec.gen - -object Codecs extends Codecs diff --git a/domain/users/query/codecs/src/Codecs.scala b/domain/users/query/codecs/src/Codecs.scala new file mode 100644 index 0000000..d30f318 --- /dev/null +++ b/domain/users/query/codecs/src/Codecs.scala @@ -0,0 +1,21 @@ +package mdr.pdb +package users.query +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends mdr.pdb.codecs.JsonCodecs: + given JsonCodec[UserContract] = DeriveJsonCodec.gen + given JsonCodec[UserFunction] = DeriveJsonCodec.gen + given JsonCodec[UserInfo] = DeriveJsonCodec.gen + given JsonCodec[UserProfile] = DeriveJsonCodec.gen + +trait TapirCodecs extends mdr.pdb.codecs.TapirCodecs: + given Schema[UserContract] = Schema.derived + given Schema[UserFunction] = Schema.derived + given Schema[UserInfo] = Schema.derived + given Schema[UserProfile] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/users/query/endpoints/src/Endpoints.scala b/domain/users/query/endpoints/src/Endpoints.scala index de0b074..16bd210 100644 --- a/domain/users/query/endpoints/src/Endpoints.scala +++ b/domain/users/query/endpoints/src/Endpoints.scala @@ -3,7 +3,7 @@ import fiftyforms.tapir.CustomTapir import fiftyforms.tapir.ServerError -import mdr.pdb.users.query.json.Codecs +import mdr.pdb.users.query.codecs.Codecs object Endpoints extends mdr.pdb.endpoints.Endpoints diff --git a/domain/users/query/json/src/Codecs.scala b/domain/users/query/json/src/Codecs.scala deleted file mode 100644 index df7edd1..0000000 --- a/domain/users/query/json/src/Codecs.scala +++ /dev/null @@ -1,14 +0,0 @@ -package mdr.pdb -package users.query -package json - -import zio.json.* - -trait Codecs extends mdr.pdb.json.Codecs: - - given JsonCodec[UserContract] = DeriveJsonCodec.gen - given JsonCodec[UserFunction] = DeriveJsonCodec.gen - given JsonCodec[UserInfo] = DeriveJsonCodec.gen - given JsonCodec[UserProfile] = DeriveJsonCodec.gen - -object Codecs extends Codecs diff --git a/domain/users/query/repo/src/UsersRepository.scala b/domain/users/query/repo/src/UsersRepository.scala index 71b0674..6ae948e 100644 --- a/domain/users/query/repo/src/UsersRepository.scala +++ b/domain/users/query/repo/src/UsersRepository.scala @@ -18,7 +18,7 @@ ZLayer.fromZIO { val readUsers: Task[List[UserInfo]] = import zio.json.{*, given} - import mdr.pdb.users.query.json.Codecs.given + import mdr.pdb.users.query.codecs.Codecs.given for maybeUsers <- readJsonAs( getClass.getResource("/users.json") diff --git a/build.sbt b/build.sbt index e6f5e40..227e292 100644 --- a/build.sbt +++ b/build.sbt @@ -14,38 +14,38 @@ lazy val proof = entityProject("proof", file("domain/proof")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .repo(_.dependsOn(`mongo-support`)) .entity(_.dependsOn(`akka-persistence-support`)) .projection(_.dependsOn(`akka-persistence-support`)) - .endpoints(_.dependsOn(`tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val parameters = entityProject("parameters", file("domain/parameters")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) - .endpoints(_.dependsOn(`tapir-support`)) + .codecs(_.dependsOn(codecs, `tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val users = entityProject("users", file("domain/users")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val core = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("core")) -lazy val json = crossProject(JSPlatform, JVMPlatform) +lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) - .in(file("json")) + .in(file("codecs")) .settings(IWDeps.zioJson) - .dependsOn(core) + .dependsOn(core, `tapir-support`) lazy val endpoints = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("endpoints")) - .dependsOn(core, json, `tapir-support`) + .dependsOn(core, codecs, `tapir-support`) // TODO: move all from fiftyforms to iterative works lazy val ui = (project in file("fiftyforms/ui")) @@ -80,7 +80,11 @@ lazy val `akka-persistence-support` = project .in(file("fiftyforms/akka-persistence")) - .settings(IWDeps.akka.profiles.eventsourcedJdbcProjection) + .settings( + IWDeps.akka.libs.persistence, + libraryDependencies += "com.typesafe.akka" %% "akka-cluster-sharding-typed" % IWDeps.akka.V, + IWDeps.akka.profiles.eventsourcedJdbcProjection + ) lazy val app = (project in file("app")) .enablePlugins(ScalaJSPlugin, VitePlugin) @@ -166,6 +170,7 @@ proof.query.api, proof.command.api, proof.query.projection, + proof.command.entity, endpoints.jvm ) diff --git a/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala new file mode 100644 index 0000000..22ebfb8 --- /dev/null +++ b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala @@ -0,0 +1,26 @@ +package mdr.pdb +package codecs + +import zio.json.* +import fiftyforms.tapir.CustomTapir + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + given JsonCodec[WhoWhen] = DeriveJsonCodec.gen + given JsonCodec[OsobniCislo] = + JsonCodec.string.transform(OsobniCislo.apply, _.toString) + given JsonFieldEncoder[OsobniCislo] = + JsonFieldEncoder.string.contramap(_.toString) + given JsonFieldDecoder[OsobniCislo] = + JsonFieldDecoder.string.map(OsobniCislo(_)) + +trait TapirCodecs extends CustomTapir: + given Schema[OsobniCislo] = Schema.string + given Codec.PlainCodec[OsobniCislo] = + Codec.string.mapDecode(OsobniCislo.apply andThen DecodeResult.Value.apply)( + _.toString + ) + given Schema[WhoWhen] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/parameters/shared/codecs/src/Codecs.scala b/domain/parameters/shared/codecs/src/Codecs.scala new file mode 100644 index 0000000..751e79b --- /dev/null +++ b/domain/parameters/shared/codecs/src/Codecs.scala @@ -0,0 +1,10 @@ +package mdr.pdb +package parameters +package codecs + +import zio.json.* + +trait Codecs: + + given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen + given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/parameters/shared/json/src/Codecs.scala b/domain/parameters/shared/json/src/Codecs.scala deleted file mode 100644 index b849072..0000000 --- a/domain/parameters/shared/json/src/Codecs.scala +++ /dev/null @@ -1,10 +0,0 @@ -package mdr.pdb -package parameters -package json - -import zio.json.* - -trait Codecs: - - given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen - given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/proof/command/api/src/ProofCommandApi.scala b/domain/proof/command/api/src/ProofCommandApi.scala new file mode 100644 index 0000000..12f747e --- /dev/null +++ b/domain/proof/command/api/src/ProofCommandApi.scala @@ -0,0 +1,16 @@ +package mdr.pdb.proof.command +package api + +import endpoints.Endpoints +import entity.ProofCommandBus +import fiftyforms.tapir.CustomTapir +import fiftyforms.tapir.InternalServerError + +object ProofCommandApi extends CustomTapir: + + val submitCommand: ZServerEndpoint[ProofCommandBus, Any] = + Endpoints.submitCommand.zServerLogic(cmd => + ProofCommandBus + .submitCommand(cmd) + .mapError(InternalServerError.fromThrowable) + ) diff --git a/domain/proof/command/codecs/src/Codecs.scala b/domain/proof/command/codecs/src/Codecs.scala new file mode 100644 index 0000000..0e75f82 --- /dev/null +++ b/domain/proof/command/codecs/src/Codecs.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof.command +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends proof.codecs.JsonCodecs: + + given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen + given JsonCodec[CreateProof] = DeriveJsonCodec.gen + given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen + given JsonCodec[UpdateProof] = DeriveJsonCodec.gen + given JsonCodec[RevokeProof] = DeriveJsonCodec.gen + given JsonCodec[Command] = DeriveJsonCodec.gen + +trait TapirCodecs extends proof.codecs.TapirCodecs: + + given Schema[AuthorizeOption] = Schema.derived + given Schema[CreateProof] = Schema.derived + given Schema[AuthorizeProof] = Schema.derived + given Schema[UpdateProof] = Schema.derived + given Schema[RevokeProof] = Schema.derived + given Schema[Command] = Schema.derived diff --git a/domain/proof/command/endpoints/src/Endpoints.scala b/domain/proof/command/endpoints/src/Endpoints.scala new file mode 100644 index 0000000..dcd114e --- /dev/null +++ b/domain/proof/command/endpoints/src/Endpoints.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof +package command +package endpoints + +import fiftyforms.tapir.CustomTapir +import mdr.pdb.proof.command.codecs.Codecs +import fiftyforms.tapir.ServerError +import zio.json.JsonCodec +import zio.json.DeriveJsonCodec +import sttp.model.StatusCode + +object Endpoints + extends mdr.pdb.endpoints.Endpoints + with CustomTapir + with Codecs: + + val submitCommand: Endpoint[Unit, Command, ServerError, Unit, Any] = + endpoint + .in("command") + .in("proof") + .post + .in(jsonBody[Command]) + .out(statusCode(StatusCode.Accepted)) + .errorOut(jsonBody[ServerError]) diff --git a/domain/proof/command/entity/src/ProofBehavior.scala b/domain/proof/command/entity/src/ProofBehavior.scala new file mode 100644 index 0000000..f679981 --- /dev/null +++ b/domain/proof/command/entity/src/ProofBehavior.scala @@ -0,0 +1,120 @@ +package mdr.pdb +package proof +package command +package entity + +import akka.persistence.typed.scaladsl.Effect +import akka.persistence.typed.scaladsl.EventSourcedBehavior +import akka.persistence.typed.PersistenceId +import akka.actor.typed.Behavior +import akka.actor.typed.ActorRef +import akka.pattern.StatusReply +import akka.Done + +import fiftyforms.akka.* +import akka.actor.typed.ActorSystem +import akka.cluster.sharding.typed.scaladsl.EntityTypeKey +import akka.cluster.sharding.typed.scaladsl.EntityContext +import akka.cluster.sharding.typed.scaladsl.ClusterSharding +import akka.cluster.sharding.typed.scaladsl.Entity + +object ProofBehavior: + + type ReplyTo = ActorRef[StatusReply[Done]] + + case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) + + type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] + + type ProofReplyEffect = + akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] + + val EntityKey: EntityTypeKey[ProofCommand] = EntityTypeKey("Proof") + + def init(system: ActorSystem[_]): Unit = + val behaviorFactory: EntityContext[ProofCommand] => Behavior[ProofCommand] = + entityContext => ProofBehavior(entityContext.entityId) + ClusterSharding(system).init(Entity(EntityKey)(behaviorFactory)) + + def apply(persistenceId: String): Behavior[ProofCommand] = + import ProofEventHandler.* + EventSourcedBehavior + .withEnforcedReplies[ProofCommand, ProofEvent, State]( + persistenceId = PersistenceId.ofUniqueId(persistenceId), + emptyState = None, + commandHandler = handleProofCommand, + eventHandler = (state, event) => + state.handleEvent(event).orElse(unhandledEvent(event, state)) + ) + .withTagger(_ => Set(ProofEvent.Tag)) + + def handleProofCommand( + state: State, + command: ProofCommand + ): ProofReplyEffect = + handleCommand(state, command.command) match + case Some(events) => persist(events, command.meta, command.replyTo) + case _ => unhandled(command.command, command.replyTo) + + def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = + state match + case None => + cmd match + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + Authorized(note) + ) => + Some( + Seq( + ProofCreated(id, person, parameterId, criterionId, documents), + ProofAuthorized(id, note) + ) + ) + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + _ + ) => + Some( + Seq(ProofCreated(id, person, parameterId, criterionId, documents)) + ) + case _ => None + case Some(proof) if proof.isRevoked => None + case Some(proof) if proof.isAuthorized => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + case Some(proof) => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case UpdateProof(id, documents) => + Some(Seq(ProofUpdated(id, documents))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + + private def persist( + events: Seq[Event], + meta: WW, + replyTo: ReplyTo + ): ProofReplyEffect = + Effect + .persist(events.map(ProofEvent(_, meta))) + .thenReply(replyTo)(_ => StatusReply.Ack) + + private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = + Effect.unhandled.thenReply(replyTo)(s => + StatusReply.error(CommandNotAvailable(command, s)) + ) + + private def unhandledEvent(event: ProofEvent, state: State): Nothing = + throw UnhandledEvent(event, state) diff --git a/domain/proof/command/entity/src/ProofBehaviour.scala b/domain/proof/command/entity/src/ProofBehaviour.scala deleted file mode 100644 index e8c7e4e..0000000 --- a/domain/proof/command/entity/src/ProofBehaviour.scala +++ /dev/null @@ -1,108 +0,0 @@ -package mdr.pdb -package proof -package command -package entity - -import akka.persistence.typed.scaladsl.EventSourcedBehavior -import akka.persistence.typed.PersistenceId -import akka.actor.typed.Behavior -import akka.actor.typed.ActorRef -import akka.pattern.StatusReply -import akka.Done - -import fiftyforms.akka.* -import akka.persistence.typed.scaladsl.Effect - -object ProofBehaviour: - - type ReplyTo = ActorRef[StatusReply[Done]] - - case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) - - type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] - - type ProofReplyEffect = - akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] - - def apply(persistenceId: PersistenceId): Behavior[ProofCommand] = - import ProofEventHandler.* - EventSourcedBehavior - .withEnforcedReplies[ProofCommand, ProofEvent, State]( - persistenceId = persistenceId, - emptyState = None, - commandHandler = handleProofCommand, - eventHandler = (state, event) => - state.handleEvent(event).orElse(unhandledEvent(event, state)) - ) - .withTagger(_ => Set(ProofEvent.Tag)) - - def handleProofCommand( - state: State, - command: ProofCommand - ): ProofReplyEffect = - handleCommand(state, command.command) match - case Some(events) => persist(events, command.meta, command.replyTo) - case _ => unhandled(command.command, command.replyTo) - - def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = - state match - case None => - cmd match - case CreateProof( - id, - person, - parameterId, - criterionId, - documents, - Authorized(note) - ) => - Some( - Seq( - ProofCreated(id, person, parameterId, criterionId, documents), - ProofAuthorized(id, note) - ) - ) - case CreateProof( - id, - person, - parameterId, - criterionId, - documents, - _ - ) => - Some( - Seq(ProofCreated(id, person, parameterId, criterionId, documents)) - ) - case _ => None - case Some(proof) if proof.isRevoked => None - case Some(proof) if proof.isAuthorized => - cmd match - case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) - case RevokeProof(id, reason, since, documents) => - Some(Seq((ProofRevoked(id, reason, since, documents)))) - case _ => None - case Some(proof) => - cmd match - case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) - case UpdateProof(id, documents) => - Some(Seq(ProofUpdated(id, documents))) - case RevokeProof(id, reason, since, documents) => - Some(Seq((ProofRevoked(id, reason, since, documents)))) - case _ => None - - private def persist( - events: Seq[Event], - meta: WW, - replyTo: ReplyTo - ): ProofReplyEffect = - Effect - .persist(events.map(ProofEvent(_, meta))) - .thenReply(replyTo)(_ => StatusReply.Ack) - - private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = - Effect.unhandled.thenReply(replyTo)(s => - StatusReply.error(CommandNotAvailable(command, s)) - ) - - private def unhandledEvent(event: ProofEvent, state: State): Nothing = - throw UnhandledEvent(event, state) diff --git a/domain/proof/command/entity/src/ProofCommandBus.scala b/domain/proof/command/entity/src/ProofCommandBus.scala new file mode 100644 index 0000000..3167e02 --- /dev/null +++ b/domain/proof/command/entity/src/ProofCommandBus.scala @@ -0,0 +1,50 @@ +package mdr.pdb +package proof +package command +package entity + +import zio.* +import akka.actor.typed.ActorSystem +import akka.cluster.sharding.typed.scaladsl.EntityContext +import akka.actor.typed.Behavior +import akka.cluster.sharding.typed.scaladsl.ClusterSharding +import akka.util.Timeout +import java.time.Instant + +object ProofCommandBus: + def submitCommand(command: Command): RIO[ProofCommandBus, Unit] = + ZIO.serviceWithZIO(_.submitCommand(command)) + + val layer: RLayer[ActorSystem[_], ProofCommandBus] = + ZIO + .serviceWithZIO[ActorSystem[_]](system => + for + timeout <- Task.attempt( + Timeout.create( + system.settings.config.getDuration("proof-bus.timeout") + ) + ) + // TODO: init only once + _ <- Task.attempt(ProofBehavior.init(system)) + yield ProofCommandBus(system)(using timeout) + ) + .toLayer + +class ProofCommandBus(system: ActorSystem[_])(using timeout: Timeout): + private val sharding = ClusterSharding(system) + + def submitCommand(command: Command): Task[Unit] = + for + entityRef <- Task.attempt( + sharding.entityRefFor(ProofBehavior.EntityKey, command.id) + ) + reply <- ZIO.fromFuture(_ => + entityRef.askWithStatus( + ProofBehavior.ProofCommand( + command, + WhoWhen(OsobniCislo("0123"), Instant.now()), + _ + ) + ) + ) + yield () diff --git a/domain/proof/command/json/src/Codecs.scala b/domain/proof/command/json/src/Codecs.scala deleted file mode 100644 index 6867615..0000000 --- a/domain/proof/command/json/src/Codecs.scala +++ /dev/null @@ -1,13 +0,0 @@ -package mdr.pdb -package proof.command -package json - -import zio.json.* - -trait Codecs extends proof.json.Codecs: - - given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen - given JsonCodec[CreateProof] = DeriveJsonCodec.gen - given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen - given JsonCodec[UpdateProof] = DeriveJsonCodec.gen - given JsonCodec[RevokeProof] = DeriveJsonCodec.gen diff --git a/domain/proof/command/model/src/Command.scala b/domain/proof/command/model/src/Command.scala index 35a15af..1bff3cd 100644 --- a/domain/proof/command/model/src/Command.scala +++ b/domain/proof/command/model/src/Command.scala @@ -5,7 +5,8 @@ import java.time.LocalDate import java.time.Instant -sealed trait Command +sealed trait Command: + def id: Proof.Id sealed trait AuthorizeOption case object Unauthorized extends AuthorizeOption diff --git a/domain/proof/query/api/src/ProofQueryApi.scala b/domain/proof/query/api/src/ProofQueryApi.scala new file mode 100644 index 0000000..19acd07 --- /dev/null +++ b/domain/proof/query/api/src/ProofQueryApi.scala @@ -0,0 +1,16 @@ +package mdr.pdb.proof.query +package api + +import endpoints.Endpoints +import repo.ProofRepository +import fiftyforms.tapir.CustomTapir +import fiftyforms.tapir.InternalServerError + +object ProofQueryApi extends CustomTapir: + + val forUser: ZServerEndpoint[ProofRepository, Any] = + Endpoints.forUser.zServerLogic(osc => + ProofRepository + .matching(ProofRepository.OfPerson(osc)) + .mapError(InternalServerError.fromThrowable) + ) diff --git a/domain/proof/query/endpoints/src/Endpoints.scala b/domain/proof/query/endpoints/src/Endpoints.scala new file mode 100644 index 0000000..e3d9fb9 --- /dev/null +++ b/domain/proof/query/endpoints/src/Endpoints.scala @@ -0,0 +1,21 @@ +package mdr.pdb +package proof +package query.endpoints + +import fiftyforms.tapir.CustomTapir +import mdr.pdb.proof.codecs.Codecs +import fiftyforms.tapir.ServerError +import zio.json.JsonCodec +import zio.json.DeriveJsonCodec + +object Endpoints + extends mdr.pdb.endpoints.Endpoints + with CustomTapir + with Codecs: + + val forUser: Endpoint[Unit, OsobniCislo, ServerError, Seq[Proof], Any] = + endpoint + .in("proof") + .in(path[OsobniCislo]("osobniCislo")) + .out(jsonBody[Seq[Proof]]) + .errorOut(jsonBody[ServerError]) diff --git a/domain/proof/query/repo/src/MongoProofRepository.scala b/domain/proof/query/repo/src/MongoProofRepository.scala index fad055c..cf19e7a 100644 --- a/domain/proof/query/repo/src/MongoProofRepository.scala +++ b/domain/proof/query/repo/src/MongoProofRepository.scala @@ -9,6 +9,7 @@ import org.bson.json.JsonObject import fiftyforms.mongo.MongoJsonRepository +// TODO: extract common mongo repo config, just nest under mongo / case class MongoProofConfig(db: String, collection: String) object MongoProofConfig { @@ -47,7 +48,7 @@ collection: MongoCollection[JsonObject] ) extends ProofRepositoryWrite: import ProofRepository.* - import mdr.pdb.proof.json.Codecs.given + import mdr.pdb.proof.codecs.Codecs.given private val jsonRepo = MongoJsonRepository[Proof, String, Criteria]( collection, { diff --git a/domain/proof/shared/codecs/src/Codecs.scala b/domain/proof/shared/codecs/src/Codecs.scala new file mode 100644 index 0000000..068f37b --- /dev/null +++ b/domain/proof/shared/codecs/src/Codecs.scala @@ -0,0 +1,20 @@ +package mdr.pdb.proof +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends mdr.pdb.codecs.JsonCodecs: + given JsonCodec[Authorization] = DeriveJsonCodec.gen + given JsonCodec[RevocationReason] = DeriveJsonCodec.gen + given JsonCodec[Revocation] = DeriveJsonCodec.gen + given JsonCodec[Proof] = DeriveJsonCodec.gen + +trait TapirCodecs extends mdr.pdb.codecs.TapirCodecs: + given Schema[Authorization] = Schema.derived + given Schema[RevocationReason] = Schema.derived + given Schema[Revocation] = Schema.derived + given Schema[Proof] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/proof/shared/json/src/Codecs.scala b/domain/proof/shared/json/src/Codecs.scala deleted file mode 100644 index 251550e..0000000 --- a/domain/proof/shared/json/src/Codecs.scala +++ /dev/null @@ -1,13 +0,0 @@ -package mdr.pdb.proof -package json - -import zio.json.* - -trait Codecs extends mdr.pdb.json.Codecs: - - given JsonCodec[Authorization] = DeriveJsonCodec.gen - given JsonCodec[RevocationReason] = DeriveJsonCodec.gen - given JsonCodec[Revocation] = DeriveJsonCodec.gen - given JsonCodec[Proof] = DeriveJsonCodec.gen - -object Codecs extends Codecs diff --git a/domain/users/query/codecs/src/Codecs.scala b/domain/users/query/codecs/src/Codecs.scala new file mode 100644 index 0000000..d30f318 --- /dev/null +++ b/domain/users/query/codecs/src/Codecs.scala @@ -0,0 +1,21 @@ +package mdr.pdb +package users.query +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends mdr.pdb.codecs.JsonCodecs: + given JsonCodec[UserContract] = DeriveJsonCodec.gen + given JsonCodec[UserFunction] = DeriveJsonCodec.gen + given JsonCodec[UserInfo] = DeriveJsonCodec.gen + given JsonCodec[UserProfile] = DeriveJsonCodec.gen + +trait TapirCodecs extends mdr.pdb.codecs.TapirCodecs: + given Schema[UserContract] = Schema.derived + given Schema[UserFunction] = Schema.derived + given Schema[UserInfo] = Schema.derived + given Schema[UserProfile] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/users/query/endpoints/src/Endpoints.scala b/domain/users/query/endpoints/src/Endpoints.scala index de0b074..16bd210 100644 --- a/domain/users/query/endpoints/src/Endpoints.scala +++ b/domain/users/query/endpoints/src/Endpoints.scala @@ -3,7 +3,7 @@ import fiftyforms.tapir.CustomTapir import fiftyforms.tapir.ServerError -import mdr.pdb.users.query.json.Codecs +import mdr.pdb.users.query.codecs.Codecs object Endpoints extends mdr.pdb.endpoints.Endpoints diff --git a/domain/users/query/json/src/Codecs.scala b/domain/users/query/json/src/Codecs.scala deleted file mode 100644 index df7edd1..0000000 --- a/domain/users/query/json/src/Codecs.scala +++ /dev/null @@ -1,14 +0,0 @@ -package mdr.pdb -package users.query -package json - -import zio.json.* - -trait Codecs extends mdr.pdb.json.Codecs: - - given JsonCodec[UserContract] = DeriveJsonCodec.gen - given JsonCodec[UserFunction] = DeriveJsonCodec.gen - given JsonCodec[UserInfo] = DeriveJsonCodec.gen - given JsonCodec[UserProfile] = DeriveJsonCodec.gen - -object Codecs extends Codecs diff --git a/domain/users/query/repo/src/UsersRepository.scala b/domain/users/query/repo/src/UsersRepository.scala index 71b0674..6ae948e 100644 --- a/domain/users/query/repo/src/UsersRepository.scala +++ b/domain/users/query/repo/src/UsersRepository.scala @@ -18,7 +18,7 @@ ZLayer.fromZIO { val readUsers: Task[List[UserInfo]] = import zio.json.{*, given} - import mdr.pdb.users.query.json.Codecs.given + import mdr.pdb.users.query.codecs.Codecs.given for maybeUsers <- readJsonAs( getClass.getResource("/users.json") diff --git a/endpoints/src/main/scala/mdr/pdb/endpoints/Endpoints.scala b/endpoints/src/main/scala/mdr/pdb/endpoints/Endpoints.scala index 7cb36cb..20efe9d 100644 --- a/endpoints/src/main/scala/mdr/pdb/endpoints/Endpoints.scala +++ b/endpoints/src/main/scala/mdr/pdb/endpoints/Endpoints.scala @@ -2,10 +2,10 @@ package endpoints import fiftyforms.tapir.CustomTapir +import sttp.tapir.Codec.PlainCodec +import mdr.pdb.codecs.TapirCodecs -trait Endpoints extends CustomTapir: - given schemaForOsobniCislo: Schema[OsobniCislo] = Schema.string - +trait Endpoints extends CustomTapir with TapirCodecs: val alive: Endpoint[Unit, Unit, Unit, String, Any] = endpoint.in("alive").out(stringBody) diff --git a/build.sbt b/build.sbt index e6f5e40..227e292 100644 --- a/build.sbt +++ b/build.sbt @@ -14,38 +14,38 @@ lazy val proof = entityProject("proof", file("domain/proof")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .repo(_.dependsOn(`mongo-support`)) .entity(_.dependsOn(`akka-persistence-support`)) .projection(_.dependsOn(`akka-persistence-support`)) - .endpoints(_.dependsOn(`tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val parameters = entityProject("parameters", file("domain/parameters")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) - .endpoints(_.dependsOn(`tapir-support`)) + .codecs(_.dependsOn(codecs, `tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val users = entityProject("users", file("domain/users")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val core = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("core")) -lazy val json = crossProject(JSPlatform, JVMPlatform) +lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) - .in(file("json")) + .in(file("codecs")) .settings(IWDeps.zioJson) - .dependsOn(core) + .dependsOn(core, `tapir-support`) lazy val endpoints = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("endpoints")) - .dependsOn(core, json, `tapir-support`) + .dependsOn(core, codecs, `tapir-support`) // TODO: move all from fiftyforms to iterative works lazy val ui = (project in file("fiftyforms/ui")) @@ -80,7 +80,11 @@ lazy val `akka-persistence-support` = project .in(file("fiftyforms/akka-persistence")) - .settings(IWDeps.akka.profiles.eventsourcedJdbcProjection) + .settings( + IWDeps.akka.libs.persistence, + libraryDependencies += "com.typesafe.akka" %% "akka-cluster-sharding-typed" % IWDeps.akka.V, + IWDeps.akka.profiles.eventsourcedJdbcProjection + ) lazy val app = (project in file("app")) .enablePlugins(ScalaJSPlugin, VitePlugin) @@ -166,6 +170,7 @@ proof.query.api, proof.command.api, proof.query.projection, + proof.command.entity, endpoints.jvm ) diff --git a/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala new file mode 100644 index 0000000..22ebfb8 --- /dev/null +++ b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala @@ -0,0 +1,26 @@ +package mdr.pdb +package codecs + +import zio.json.* +import fiftyforms.tapir.CustomTapir + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + given JsonCodec[WhoWhen] = DeriveJsonCodec.gen + given JsonCodec[OsobniCislo] = + JsonCodec.string.transform(OsobniCislo.apply, _.toString) + given JsonFieldEncoder[OsobniCislo] = + JsonFieldEncoder.string.contramap(_.toString) + given JsonFieldDecoder[OsobniCislo] = + JsonFieldDecoder.string.map(OsobniCislo(_)) + +trait TapirCodecs extends CustomTapir: + given Schema[OsobniCislo] = Schema.string + given Codec.PlainCodec[OsobniCislo] = + Codec.string.mapDecode(OsobniCislo.apply andThen DecodeResult.Value.apply)( + _.toString + ) + given Schema[WhoWhen] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/parameters/shared/codecs/src/Codecs.scala b/domain/parameters/shared/codecs/src/Codecs.scala new file mode 100644 index 0000000..751e79b --- /dev/null +++ b/domain/parameters/shared/codecs/src/Codecs.scala @@ -0,0 +1,10 @@ +package mdr.pdb +package parameters +package codecs + +import zio.json.* + +trait Codecs: + + given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen + given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/parameters/shared/json/src/Codecs.scala b/domain/parameters/shared/json/src/Codecs.scala deleted file mode 100644 index b849072..0000000 --- a/domain/parameters/shared/json/src/Codecs.scala +++ /dev/null @@ -1,10 +0,0 @@ -package mdr.pdb -package parameters -package json - -import zio.json.* - -trait Codecs: - - given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen - given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/proof/command/api/src/ProofCommandApi.scala b/domain/proof/command/api/src/ProofCommandApi.scala new file mode 100644 index 0000000..12f747e --- /dev/null +++ b/domain/proof/command/api/src/ProofCommandApi.scala @@ -0,0 +1,16 @@ +package mdr.pdb.proof.command +package api + +import endpoints.Endpoints +import entity.ProofCommandBus +import fiftyforms.tapir.CustomTapir +import fiftyforms.tapir.InternalServerError + +object ProofCommandApi extends CustomTapir: + + val submitCommand: ZServerEndpoint[ProofCommandBus, Any] = + Endpoints.submitCommand.zServerLogic(cmd => + ProofCommandBus + .submitCommand(cmd) + .mapError(InternalServerError.fromThrowable) + ) diff --git a/domain/proof/command/codecs/src/Codecs.scala b/domain/proof/command/codecs/src/Codecs.scala new file mode 100644 index 0000000..0e75f82 --- /dev/null +++ b/domain/proof/command/codecs/src/Codecs.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof.command +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends proof.codecs.JsonCodecs: + + given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen + given JsonCodec[CreateProof] = DeriveJsonCodec.gen + given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen + given JsonCodec[UpdateProof] = DeriveJsonCodec.gen + given JsonCodec[RevokeProof] = DeriveJsonCodec.gen + given JsonCodec[Command] = DeriveJsonCodec.gen + +trait TapirCodecs extends proof.codecs.TapirCodecs: + + given Schema[AuthorizeOption] = Schema.derived + given Schema[CreateProof] = Schema.derived + given Schema[AuthorizeProof] = Schema.derived + given Schema[UpdateProof] = Schema.derived + given Schema[RevokeProof] = Schema.derived + given Schema[Command] = Schema.derived diff --git a/domain/proof/command/endpoints/src/Endpoints.scala b/domain/proof/command/endpoints/src/Endpoints.scala new file mode 100644 index 0000000..dcd114e --- /dev/null +++ b/domain/proof/command/endpoints/src/Endpoints.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof +package command +package endpoints + +import fiftyforms.tapir.CustomTapir +import mdr.pdb.proof.command.codecs.Codecs +import fiftyforms.tapir.ServerError +import zio.json.JsonCodec +import zio.json.DeriveJsonCodec +import sttp.model.StatusCode + +object Endpoints + extends mdr.pdb.endpoints.Endpoints + with CustomTapir + with Codecs: + + val submitCommand: Endpoint[Unit, Command, ServerError, Unit, Any] = + endpoint + .in("command") + .in("proof") + .post + .in(jsonBody[Command]) + .out(statusCode(StatusCode.Accepted)) + .errorOut(jsonBody[ServerError]) diff --git a/domain/proof/command/entity/src/ProofBehavior.scala b/domain/proof/command/entity/src/ProofBehavior.scala new file mode 100644 index 0000000..f679981 --- /dev/null +++ b/domain/proof/command/entity/src/ProofBehavior.scala @@ -0,0 +1,120 @@ +package mdr.pdb +package proof +package command +package entity + +import akka.persistence.typed.scaladsl.Effect +import akka.persistence.typed.scaladsl.EventSourcedBehavior +import akka.persistence.typed.PersistenceId +import akka.actor.typed.Behavior +import akka.actor.typed.ActorRef +import akka.pattern.StatusReply +import akka.Done + +import fiftyforms.akka.* +import akka.actor.typed.ActorSystem +import akka.cluster.sharding.typed.scaladsl.EntityTypeKey +import akka.cluster.sharding.typed.scaladsl.EntityContext +import akka.cluster.sharding.typed.scaladsl.ClusterSharding +import akka.cluster.sharding.typed.scaladsl.Entity + +object ProofBehavior: + + type ReplyTo = ActorRef[StatusReply[Done]] + + case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) + + type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] + + type ProofReplyEffect = + akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] + + val EntityKey: EntityTypeKey[ProofCommand] = EntityTypeKey("Proof") + + def init(system: ActorSystem[_]): Unit = + val behaviorFactory: EntityContext[ProofCommand] => Behavior[ProofCommand] = + entityContext => ProofBehavior(entityContext.entityId) + ClusterSharding(system).init(Entity(EntityKey)(behaviorFactory)) + + def apply(persistenceId: String): Behavior[ProofCommand] = + import ProofEventHandler.* + EventSourcedBehavior + .withEnforcedReplies[ProofCommand, ProofEvent, State]( + persistenceId = PersistenceId.ofUniqueId(persistenceId), + emptyState = None, + commandHandler = handleProofCommand, + eventHandler = (state, event) => + state.handleEvent(event).orElse(unhandledEvent(event, state)) + ) + .withTagger(_ => Set(ProofEvent.Tag)) + + def handleProofCommand( + state: State, + command: ProofCommand + ): ProofReplyEffect = + handleCommand(state, command.command) match + case Some(events) => persist(events, command.meta, command.replyTo) + case _ => unhandled(command.command, command.replyTo) + + def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = + state match + case None => + cmd match + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + Authorized(note) + ) => + Some( + Seq( + ProofCreated(id, person, parameterId, criterionId, documents), + ProofAuthorized(id, note) + ) + ) + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + _ + ) => + Some( + Seq(ProofCreated(id, person, parameterId, criterionId, documents)) + ) + case _ => None + case Some(proof) if proof.isRevoked => None + case Some(proof) if proof.isAuthorized => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + case Some(proof) => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case UpdateProof(id, documents) => + Some(Seq(ProofUpdated(id, documents))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + + private def persist( + events: Seq[Event], + meta: WW, + replyTo: ReplyTo + ): ProofReplyEffect = + Effect + .persist(events.map(ProofEvent(_, meta))) + .thenReply(replyTo)(_ => StatusReply.Ack) + + private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = + Effect.unhandled.thenReply(replyTo)(s => + StatusReply.error(CommandNotAvailable(command, s)) + ) + + private def unhandledEvent(event: ProofEvent, state: State): Nothing = + throw UnhandledEvent(event, state) diff --git a/domain/proof/command/entity/src/ProofBehaviour.scala b/domain/proof/command/entity/src/ProofBehaviour.scala deleted file mode 100644 index e8c7e4e..0000000 --- a/domain/proof/command/entity/src/ProofBehaviour.scala +++ /dev/null @@ -1,108 +0,0 @@ -package mdr.pdb -package proof -package command -package entity - -import akka.persistence.typed.scaladsl.EventSourcedBehavior -import akka.persistence.typed.PersistenceId -import akka.actor.typed.Behavior -import akka.actor.typed.ActorRef -import akka.pattern.StatusReply -import akka.Done - -import fiftyforms.akka.* -import akka.persistence.typed.scaladsl.Effect - -object ProofBehaviour: - - type ReplyTo = ActorRef[StatusReply[Done]] - - case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) - - type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] - - type ProofReplyEffect = - akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] - - def apply(persistenceId: PersistenceId): Behavior[ProofCommand] = - import ProofEventHandler.* - EventSourcedBehavior - .withEnforcedReplies[ProofCommand, ProofEvent, State]( - persistenceId = persistenceId, - emptyState = None, - commandHandler = handleProofCommand, - eventHandler = (state, event) => - state.handleEvent(event).orElse(unhandledEvent(event, state)) - ) - .withTagger(_ => Set(ProofEvent.Tag)) - - def handleProofCommand( - state: State, - command: ProofCommand - ): ProofReplyEffect = - handleCommand(state, command.command) match - case Some(events) => persist(events, command.meta, command.replyTo) - case _ => unhandled(command.command, command.replyTo) - - def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = - state match - case None => - cmd match - case CreateProof( - id, - person, - parameterId, - criterionId, - documents, - Authorized(note) - ) => - Some( - Seq( - ProofCreated(id, person, parameterId, criterionId, documents), - ProofAuthorized(id, note) - ) - ) - case CreateProof( - id, - person, - parameterId, - criterionId, - documents, - _ - ) => - Some( - Seq(ProofCreated(id, person, parameterId, criterionId, documents)) - ) - case _ => None - case Some(proof) if proof.isRevoked => None - case Some(proof) if proof.isAuthorized => - cmd match - case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) - case RevokeProof(id, reason, since, documents) => - Some(Seq((ProofRevoked(id, reason, since, documents)))) - case _ => None - case Some(proof) => - cmd match - case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) - case UpdateProof(id, documents) => - Some(Seq(ProofUpdated(id, documents))) - case RevokeProof(id, reason, since, documents) => - Some(Seq((ProofRevoked(id, reason, since, documents)))) - case _ => None - - private def persist( - events: Seq[Event], - meta: WW, - replyTo: ReplyTo - ): ProofReplyEffect = - Effect - .persist(events.map(ProofEvent(_, meta))) - .thenReply(replyTo)(_ => StatusReply.Ack) - - private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = - Effect.unhandled.thenReply(replyTo)(s => - StatusReply.error(CommandNotAvailable(command, s)) - ) - - private def unhandledEvent(event: ProofEvent, state: State): Nothing = - throw UnhandledEvent(event, state) diff --git a/domain/proof/command/entity/src/ProofCommandBus.scala b/domain/proof/command/entity/src/ProofCommandBus.scala new file mode 100644 index 0000000..3167e02 --- /dev/null +++ b/domain/proof/command/entity/src/ProofCommandBus.scala @@ -0,0 +1,50 @@ +package mdr.pdb +package proof +package command +package entity + +import zio.* +import akka.actor.typed.ActorSystem +import akka.cluster.sharding.typed.scaladsl.EntityContext +import akka.actor.typed.Behavior +import akka.cluster.sharding.typed.scaladsl.ClusterSharding +import akka.util.Timeout +import java.time.Instant + +object ProofCommandBus: + def submitCommand(command: Command): RIO[ProofCommandBus, Unit] = + ZIO.serviceWithZIO(_.submitCommand(command)) + + val layer: RLayer[ActorSystem[_], ProofCommandBus] = + ZIO + .serviceWithZIO[ActorSystem[_]](system => + for + timeout <- Task.attempt( + Timeout.create( + system.settings.config.getDuration("proof-bus.timeout") + ) + ) + // TODO: init only once + _ <- Task.attempt(ProofBehavior.init(system)) + yield ProofCommandBus(system)(using timeout) + ) + .toLayer + +class ProofCommandBus(system: ActorSystem[_])(using timeout: Timeout): + private val sharding = ClusterSharding(system) + + def submitCommand(command: Command): Task[Unit] = + for + entityRef <- Task.attempt( + sharding.entityRefFor(ProofBehavior.EntityKey, command.id) + ) + reply <- ZIO.fromFuture(_ => + entityRef.askWithStatus( + ProofBehavior.ProofCommand( + command, + WhoWhen(OsobniCislo("0123"), Instant.now()), + _ + ) + ) + ) + yield () diff --git a/domain/proof/command/json/src/Codecs.scala b/domain/proof/command/json/src/Codecs.scala deleted file mode 100644 index 6867615..0000000 --- a/domain/proof/command/json/src/Codecs.scala +++ /dev/null @@ -1,13 +0,0 @@ -package mdr.pdb -package proof.command -package json - -import zio.json.* - -trait Codecs extends proof.json.Codecs: - - given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen - given JsonCodec[CreateProof] = DeriveJsonCodec.gen - given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen - given JsonCodec[UpdateProof] = DeriveJsonCodec.gen - given JsonCodec[RevokeProof] = DeriveJsonCodec.gen diff --git a/domain/proof/command/model/src/Command.scala b/domain/proof/command/model/src/Command.scala index 35a15af..1bff3cd 100644 --- a/domain/proof/command/model/src/Command.scala +++ b/domain/proof/command/model/src/Command.scala @@ -5,7 +5,8 @@ import java.time.LocalDate import java.time.Instant -sealed trait Command +sealed trait Command: + def id: Proof.Id sealed trait AuthorizeOption case object Unauthorized extends AuthorizeOption diff --git a/domain/proof/query/api/src/ProofQueryApi.scala b/domain/proof/query/api/src/ProofQueryApi.scala new file mode 100644 index 0000000..19acd07 --- /dev/null +++ b/domain/proof/query/api/src/ProofQueryApi.scala @@ -0,0 +1,16 @@ +package mdr.pdb.proof.query +package api + +import endpoints.Endpoints +import repo.ProofRepository +import fiftyforms.tapir.CustomTapir +import fiftyforms.tapir.InternalServerError + +object ProofQueryApi extends CustomTapir: + + val forUser: ZServerEndpoint[ProofRepository, Any] = + Endpoints.forUser.zServerLogic(osc => + ProofRepository + .matching(ProofRepository.OfPerson(osc)) + .mapError(InternalServerError.fromThrowable) + ) diff --git a/domain/proof/query/endpoints/src/Endpoints.scala b/domain/proof/query/endpoints/src/Endpoints.scala new file mode 100644 index 0000000..e3d9fb9 --- /dev/null +++ b/domain/proof/query/endpoints/src/Endpoints.scala @@ -0,0 +1,21 @@ +package mdr.pdb +package proof +package query.endpoints + +import fiftyforms.tapir.CustomTapir +import mdr.pdb.proof.codecs.Codecs +import fiftyforms.tapir.ServerError +import zio.json.JsonCodec +import zio.json.DeriveJsonCodec + +object Endpoints + extends mdr.pdb.endpoints.Endpoints + with CustomTapir + with Codecs: + + val forUser: Endpoint[Unit, OsobniCislo, ServerError, Seq[Proof], Any] = + endpoint + .in("proof") + .in(path[OsobniCislo]("osobniCislo")) + .out(jsonBody[Seq[Proof]]) + .errorOut(jsonBody[ServerError]) diff --git a/domain/proof/query/repo/src/MongoProofRepository.scala b/domain/proof/query/repo/src/MongoProofRepository.scala index fad055c..cf19e7a 100644 --- a/domain/proof/query/repo/src/MongoProofRepository.scala +++ b/domain/proof/query/repo/src/MongoProofRepository.scala @@ -9,6 +9,7 @@ import org.bson.json.JsonObject import fiftyforms.mongo.MongoJsonRepository +// TODO: extract common mongo repo config, just nest under mongo / case class MongoProofConfig(db: String, collection: String) object MongoProofConfig { @@ -47,7 +48,7 @@ collection: MongoCollection[JsonObject] ) extends ProofRepositoryWrite: import ProofRepository.* - import mdr.pdb.proof.json.Codecs.given + import mdr.pdb.proof.codecs.Codecs.given private val jsonRepo = MongoJsonRepository[Proof, String, Criteria]( collection, { diff --git a/domain/proof/shared/codecs/src/Codecs.scala b/domain/proof/shared/codecs/src/Codecs.scala new file mode 100644 index 0000000..068f37b --- /dev/null +++ b/domain/proof/shared/codecs/src/Codecs.scala @@ -0,0 +1,20 @@ +package mdr.pdb.proof +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends mdr.pdb.codecs.JsonCodecs: + given JsonCodec[Authorization] = DeriveJsonCodec.gen + given JsonCodec[RevocationReason] = DeriveJsonCodec.gen + given JsonCodec[Revocation] = DeriveJsonCodec.gen + given JsonCodec[Proof] = DeriveJsonCodec.gen + +trait TapirCodecs extends mdr.pdb.codecs.TapirCodecs: + given Schema[Authorization] = Schema.derived + given Schema[RevocationReason] = Schema.derived + given Schema[Revocation] = Schema.derived + given Schema[Proof] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/proof/shared/json/src/Codecs.scala b/domain/proof/shared/json/src/Codecs.scala deleted file mode 100644 index 251550e..0000000 --- a/domain/proof/shared/json/src/Codecs.scala +++ /dev/null @@ -1,13 +0,0 @@ -package mdr.pdb.proof -package json - -import zio.json.* - -trait Codecs extends mdr.pdb.json.Codecs: - - given JsonCodec[Authorization] = DeriveJsonCodec.gen - given JsonCodec[RevocationReason] = DeriveJsonCodec.gen - given JsonCodec[Revocation] = DeriveJsonCodec.gen - given JsonCodec[Proof] = DeriveJsonCodec.gen - -object Codecs extends Codecs diff --git a/domain/users/query/codecs/src/Codecs.scala b/domain/users/query/codecs/src/Codecs.scala new file mode 100644 index 0000000..d30f318 --- /dev/null +++ b/domain/users/query/codecs/src/Codecs.scala @@ -0,0 +1,21 @@ +package mdr.pdb +package users.query +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends mdr.pdb.codecs.JsonCodecs: + given JsonCodec[UserContract] = DeriveJsonCodec.gen + given JsonCodec[UserFunction] = DeriveJsonCodec.gen + given JsonCodec[UserInfo] = DeriveJsonCodec.gen + given JsonCodec[UserProfile] = DeriveJsonCodec.gen + +trait TapirCodecs extends mdr.pdb.codecs.TapirCodecs: + given Schema[UserContract] = Schema.derived + given Schema[UserFunction] = Schema.derived + given Schema[UserInfo] = Schema.derived + given Schema[UserProfile] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/users/query/endpoints/src/Endpoints.scala b/domain/users/query/endpoints/src/Endpoints.scala index de0b074..16bd210 100644 --- a/domain/users/query/endpoints/src/Endpoints.scala +++ b/domain/users/query/endpoints/src/Endpoints.scala @@ -3,7 +3,7 @@ import fiftyforms.tapir.CustomTapir import fiftyforms.tapir.ServerError -import mdr.pdb.users.query.json.Codecs +import mdr.pdb.users.query.codecs.Codecs object Endpoints extends mdr.pdb.endpoints.Endpoints diff --git a/domain/users/query/json/src/Codecs.scala b/domain/users/query/json/src/Codecs.scala deleted file mode 100644 index df7edd1..0000000 --- a/domain/users/query/json/src/Codecs.scala +++ /dev/null @@ -1,14 +0,0 @@ -package mdr.pdb -package users.query -package json - -import zio.json.* - -trait Codecs extends mdr.pdb.json.Codecs: - - given JsonCodec[UserContract] = DeriveJsonCodec.gen - given JsonCodec[UserFunction] = DeriveJsonCodec.gen - given JsonCodec[UserInfo] = DeriveJsonCodec.gen - given JsonCodec[UserProfile] = DeriveJsonCodec.gen - -object Codecs extends Codecs diff --git a/domain/users/query/repo/src/UsersRepository.scala b/domain/users/query/repo/src/UsersRepository.scala index 71b0674..6ae948e 100644 --- a/domain/users/query/repo/src/UsersRepository.scala +++ b/domain/users/query/repo/src/UsersRepository.scala @@ -18,7 +18,7 @@ ZLayer.fromZIO { val readUsers: Task[List[UserInfo]] = import zio.json.{*, given} - import mdr.pdb.users.query.json.Codecs.given + import mdr.pdb.users.query.codecs.Codecs.given for maybeUsers <- readJsonAs( getClass.getResource("/users.json") diff --git a/endpoints/src/main/scala/mdr/pdb/endpoints/Endpoints.scala b/endpoints/src/main/scala/mdr/pdb/endpoints/Endpoints.scala index 7cb36cb..20efe9d 100644 --- a/endpoints/src/main/scala/mdr/pdb/endpoints/Endpoints.scala +++ b/endpoints/src/main/scala/mdr/pdb/endpoints/Endpoints.scala @@ -2,10 +2,10 @@ package endpoints import fiftyforms.tapir.CustomTapir +import sttp.tapir.Codec.PlainCodec +import mdr.pdb.codecs.TapirCodecs -trait Endpoints extends CustomTapir: - given schemaForOsobniCislo: Schema[OsobniCislo] = Schema.string - +trait Endpoints extends CustomTapir with TapirCodecs: val alive: Endpoint[Unit, Unit, Unit, String, Any] = endpoint.in("alive").out(stringBody) diff --git a/fiftyforms/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala b/fiftyforms/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala index eff2357..58a4cfc 100644 --- a/fiftyforms/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala +++ b/fiftyforms/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala @@ -18,11 +18,11 @@ .to[MongoConfig] val fromEnv = ZConfig.fromSystemEnv(configDesc) -val client: RLayer[MongoConfig, MongoClient] = - (for - config <- ZIO.service[MongoConfig] - client <- Task.attempt(MongoClient(config.uri)) - yield client).toLayer +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZIO + .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) + .toLayer class MongoJsonRepository[Elem, Key, Criteria]( collection: MongoCollection[JsonObject], diff --git a/build.sbt b/build.sbt index e6f5e40..227e292 100644 --- a/build.sbt +++ b/build.sbt @@ -14,38 +14,38 @@ lazy val proof = entityProject("proof", file("domain/proof")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .repo(_.dependsOn(`mongo-support`)) .entity(_.dependsOn(`akka-persistence-support`)) .projection(_.dependsOn(`akka-persistence-support`)) - .endpoints(_.dependsOn(`tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val parameters = entityProject("parameters", file("domain/parameters")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) - .endpoints(_.dependsOn(`tapir-support`)) + .codecs(_.dependsOn(codecs, `tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val users = entityProject("users", file("domain/users")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val core = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("core")) -lazy val json = crossProject(JSPlatform, JVMPlatform) +lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) - .in(file("json")) + .in(file("codecs")) .settings(IWDeps.zioJson) - .dependsOn(core) + .dependsOn(core, `tapir-support`) lazy val endpoints = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("endpoints")) - .dependsOn(core, json, `tapir-support`) + .dependsOn(core, codecs, `tapir-support`) // TODO: move all from fiftyforms to iterative works lazy val ui = (project in file("fiftyforms/ui")) @@ -80,7 +80,11 @@ lazy val `akka-persistence-support` = project .in(file("fiftyforms/akka-persistence")) - .settings(IWDeps.akka.profiles.eventsourcedJdbcProjection) + .settings( + IWDeps.akka.libs.persistence, + libraryDependencies += "com.typesafe.akka" %% "akka-cluster-sharding-typed" % IWDeps.akka.V, + IWDeps.akka.profiles.eventsourcedJdbcProjection + ) lazy val app = (project in file("app")) .enablePlugins(ScalaJSPlugin, VitePlugin) @@ -166,6 +170,7 @@ proof.query.api, proof.command.api, proof.query.projection, + proof.command.entity, endpoints.jvm ) diff --git a/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala new file mode 100644 index 0000000..22ebfb8 --- /dev/null +++ b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala @@ -0,0 +1,26 @@ +package mdr.pdb +package codecs + +import zio.json.* +import fiftyforms.tapir.CustomTapir + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + given JsonCodec[WhoWhen] = DeriveJsonCodec.gen + given JsonCodec[OsobniCislo] = + JsonCodec.string.transform(OsobniCislo.apply, _.toString) + given JsonFieldEncoder[OsobniCislo] = + JsonFieldEncoder.string.contramap(_.toString) + given JsonFieldDecoder[OsobniCislo] = + JsonFieldDecoder.string.map(OsobniCislo(_)) + +trait TapirCodecs extends CustomTapir: + given Schema[OsobniCislo] = Schema.string + given Codec.PlainCodec[OsobniCislo] = + Codec.string.mapDecode(OsobniCislo.apply andThen DecodeResult.Value.apply)( + _.toString + ) + given Schema[WhoWhen] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/parameters/shared/codecs/src/Codecs.scala b/domain/parameters/shared/codecs/src/Codecs.scala new file mode 100644 index 0000000..751e79b --- /dev/null +++ b/domain/parameters/shared/codecs/src/Codecs.scala @@ -0,0 +1,10 @@ +package mdr.pdb +package parameters +package codecs + +import zio.json.* + +trait Codecs: + + given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen + given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/parameters/shared/json/src/Codecs.scala b/domain/parameters/shared/json/src/Codecs.scala deleted file mode 100644 index b849072..0000000 --- a/domain/parameters/shared/json/src/Codecs.scala +++ /dev/null @@ -1,10 +0,0 @@ -package mdr.pdb -package parameters -package json - -import zio.json.* - -trait Codecs: - - given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen - given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/proof/command/api/src/ProofCommandApi.scala b/domain/proof/command/api/src/ProofCommandApi.scala new file mode 100644 index 0000000..12f747e --- /dev/null +++ b/domain/proof/command/api/src/ProofCommandApi.scala @@ -0,0 +1,16 @@ +package mdr.pdb.proof.command +package api + +import endpoints.Endpoints +import entity.ProofCommandBus +import fiftyforms.tapir.CustomTapir +import fiftyforms.tapir.InternalServerError + +object ProofCommandApi extends CustomTapir: + + val submitCommand: ZServerEndpoint[ProofCommandBus, Any] = + Endpoints.submitCommand.zServerLogic(cmd => + ProofCommandBus + .submitCommand(cmd) + .mapError(InternalServerError.fromThrowable) + ) diff --git a/domain/proof/command/codecs/src/Codecs.scala b/domain/proof/command/codecs/src/Codecs.scala new file mode 100644 index 0000000..0e75f82 --- /dev/null +++ b/domain/proof/command/codecs/src/Codecs.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof.command +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends proof.codecs.JsonCodecs: + + given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen + given JsonCodec[CreateProof] = DeriveJsonCodec.gen + given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen + given JsonCodec[UpdateProof] = DeriveJsonCodec.gen + given JsonCodec[RevokeProof] = DeriveJsonCodec.gen + given JsonCodec[Command] = DeriveJsonCodec.gen + +trait TapirCodecs extends proof.codecs.TapirCodecs: + + given Schema[AuthorizeOption] = Schema.derived + given Schema[CreateProof] = Schema.derived + given Schema[AuthorizeProof] = Schema.derived + given Schema[UpdateProof] = Schema.derived + given Schema[RevokeProof] = Schema.derived + given Schema[Command] = Schema.derived diff --git a/domain/proof/command/endpoints/src/Endpoints.scala b/domain/proof/command/endpoints/src/Endpoints.scala new file mode 100644 index 0000000..dcd114e --- /dev/null +++ b/domain/proof/command/endpoints/src/Endpoints.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof +package command +package endpoints + +import fiftyforms.tapir.CustomTapir +import mdr.pdb.proof.command.codecs.Codecs +import fiftyforms.tapir.ServerError +import zio.json.JsonCodec +import zio.json.DeriveJsonCodec +import sttp.model.StatusCode + +object Endpoints + extends mdr.pdb.endpoints.Endpoints + with CustomTapir + with Codecs: + + val submitCommand: Endpoint[Unit, Command, ServerError, Unit, Any] = + endpoint + .in("command") + .in("proof") + .post + .in(jsonBody[Command]) + .out(statusCode(StatusCode.Accepted)) + .errorOut(jsonBody[ServerError]) diff --git a/domain/proof/command/entity/src/ProofBehavior.scala b/domain/proof/command/entity/src/ProofBehavior.scala new file mode 100644 index 0000000..f679981 --- /dev/null +++ b/domain/proof/command/entity/src/ProofBehavior.scala @@ -0,0 +1,120 @@ +package mdr.pdb +package proof +package command +package entity + +import akka.persistence.typed.scaladsl.Effect +import akka.persistence.typed.scaladsl.EventSourcedBehavior +import akka.persistence.typed.PersistenceId +import akka.actor.typed.Behavior +import akka.actor.typed.ActorRef +import akka.pattern.StatusReply +import akka.Done + +import fiftyforms.akka.* +import akka.actor.typed.ActorSystem +import akka.cluster.sharding.typed.scaladsl.EntityTypeKey +import akka.cluster.sharding.typed.scaladsl.EntityContext +import akka.cluster.sharding.typed.scaladsl.ClusterSharding +import akka.cluster.sharding.typed.scaladsl.Entity + +object ProofBehavior: + + type ReplyTo = ActorRef[StatusReply[Done]] + + case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) + + type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] + + type ProofReplyEffect = + akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] + + val EntityKey: EntityTypeKey[ProofCommand] = EntityTypeKey("Proof") + + def init(system: ActorSystem[_]): Unit = + val behaviorFactory: EntityContext[ProofCommand] => Behavior[ProofCommand] = + entityContext => ProofBehavior(entityContext.entityId) + ClusterSharding(system).init(Entity(EntityKey)(behaviorFactory)) + + def apply(persistenceId: String): Behavior[ProofCommand] = + import ProofEventHandler.* + EventSourcedBehavior + .withEnforcedReplies[ProofCommand, ProofEvent, State]( + persistenceId = PersistenceId.ofUniqueId(persistenceId), + emptyState = None, + commandHandler = handleProofCommand, + eventHandler = (state, event) => + state.handleEvent(event).orElse(unhandledEvent(event, state)) + ) + .withTagger(_ => Set(ProofEvent.Tag)) + + def handleProofCommand( + state: State, + command: ProofCommand + ): ProofReplyEffect = + handleCommand(state, command.command) match + case Some(events) => persist(events, command.meta, command.replyTo) + case _ => unhandled(command.command, command.replyTo) + + def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = + state match + case None => + cmd match + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + Authorized(note) + ) => + Some( + Seq( + ProofCreated(id, person, parameterId, criterionId, documents), + ProofAuthorized(id, note) + ) + ) + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + _ + ) => + Some( + Seq(ProofCreated(id, person, parameterId, criterionId, documents)) + ) + case _ => None + case Some(proof) if proof.isRevoked => None + case Some(proof) if proof.isAuthorized => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + case Some(proof) => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case UpdateProof(id, documents) => + Some(Seq(ProofUpdated(id, documents))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + + private def persist( + events: Seq[Event], + meta: WW, + replyTo: ReplyTo + ): ProofReplyEffect = + Effect + .persist(events.map(ProofEvent(_, meta))) + .thenReply(replyTo)(_ => StatusReply.Ack) + + private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = + Effect.unhandled.thenReply(replyTo)(s => + StatusReply.error(CommandNotAvailable(command, s)) + ) + + private def unhandledEvent(event: ProofEvent, state: State): Nothing = + throw UnhandledEvent(event, state) diff --git a/domain/proof/command/entity/src/ProofBehaviour.scala b/domain/proof/command/entity/src/ProofBehaviour.scala deleted file mode 100644 index e8c7e4e..0000000 --- a/domain/proof/command/entity/src/ProofBehaviour.scala +++ /dev/null @@ -1,108 +0,0 @@ -package mdr.pdb -package proof -package command -package entity - -import akka.persistence.typed.scaladsl.EventSourcedBehavior -import akka.persistence.typed.PersistenceId -import akka.actor.typed.Behavior -import akka.actor.typed.ActorRef -import akka.pattern.StatusReply -import akka.Done - -import fiftyforms.akka.* -import akka.persistence.typed.scaladsl.Effect - -object ProofBehaviour: - - type ReplyTo = ActorRef[StatusReply[Done]] - - case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) - - type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] - - type ProofReplyEffect = - akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] - - def apply(persistenceId: PersistenceId): Behavior[ProofCommand] = - import ProofEventHandler.* - EventSourcedBehavior - .withEnforcedReplies[ProofCommand, ProofEvent, State]( - persistenceId = persistenceId, - emptyState = None, - commandHandler = handleProofCommand, - eventHandler = (state, event) => - state.handleEvent(event).orElse(unhandledEvent(event, state)) - ) - .withTagger(_ => Set(ProofEvent.Tag)) - - def handleProofCommand( - state: State, - command: ProofCommand - ): ProofReplyEffect = - handleCommand(state, command.command) match - case Some(events) => persist(events, command.meta, command.replyTo) - case _ => unhandled(command.command, command.replyTo) - - def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = - state match - case None => - cmd match - case CreateProof( - id, - person, - parameterId, - criterionId, - documents, - Authorized(note) - ) => - Some( - Seq( - ProofCreated(id, person, parameterId, criterionId, documents), - ProofAuthorized(id, note) - ) - ) - case CreateProof( - id, - person, - parameterId, - criterionId, - documents, - _ - ) => - Some( - Seq(ProofCreated(id, person, parameterId, criterionId, documents)) - ) - case _ => None - case Some(proof) if proof.isRevoked => None - case Some(proof) if proof.isAuthorized => - cmd match - case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) - case RevokeProof(id, reason, since, documents) => - Some(Seq((ProofRevoked(id, reason, since, documents)))) - case _ => None - case Some(proof) => - cmd match - case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) - case UpdateProof(id, documents) => - Some(Seq(ProofUpdated(id, documents))) - case RevokeProof(id, reason, since, documents) => - Some(Seq((ProofRevoked(id, reason, since, documents)))) - case _ => None - - private def persist( - events: Seq[Event], - meta: WW, - replyTo: ReplyTo - ): ProofReplyEffect = - Effect - .persist(events.map(ProofEvent(_, meta))) - .thenReply(replyTo)(_ => StatusReply.Ack) - - private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = - Effect.unhandled.thenReply(replyTo)(s => - StatusReply.error(CommandNotAvailable(command, s)) - ) - - private def unhandledEvent(event: ProofEvent, state: State): Nothing = - throw UnhandledEvent(event, state) diff --git a/domain/proof/command/entity/src/ProofCommandBus.scala b/domain/proof/command/entity/src/ProofCommandBus.scala new file mode 100644 index 0000000..3167e02 --- /dev/null +++ b/domain/proof/command/entity/src/ProofCommandBus.scala @@ -0,0 +1,50 @@ +package mdr.pdb +package proof +package command +package entity + +import zio.* +import akka.actor.typed.ActorSystem +import akka.cluster.sharding.typed.scaladsl.EntityContext +import akka.actor.typed.Behavior +import akka.cluster.sharding.typed.scaladsl.ClusterSharding +import akka.util.Timeout +import java.time.Instant + +object ProofCommandBus: + def submitCommand(command: Command): RIO[ProofCommandBus, Unit] = + ZIO.serviceWithZIO(_.submitCommand(command)) + + val layer: RLayer[ActorSystem[_], ProofCommandBus] = + ZIO + .serviceWithZIO[ActorSystem[_]](system => + for + timeout <- Task.attempt( + Timeout.create( + system.settings.config.getDuration("proof-bus.timeout") + ) + ) + // TODO: init only once + _ <- Task.attempt(ProofBehavior.init(system)) + yield ProofCommandBus(system)(using timeout) + ) + .toLayer + +class ProofCommandBus(system: ActorSystem[_])(using timeout: Timeout): + private val sharding = ClusterSharding(system) + + def submitCommand(command: Command): Task[Unit] = + for + entityRef <- Task.attempt( + sharding.entityRefFor(ProofBehavior.EntityKey, command.id) + ) + reply <- ZIO.fromFuture(_ => + entityRef.askWithStatus( + ProofBehavior.ProofCommand( + command, + WhoWhen(OsobniCislo("0123"), Instant.now()), + _ + ) + ) + ) + yield () diff --git a/domain/proof/command/json/src/Codecs.scala b/domain/proof/command/json/src/Codecs.scala deleted file mode 100644 index 6867615..0000000 --- a/domain/proof/command/json/src/Codecs.scala +++ /dev/null @@ -1,13 +0,0 @@ -package mdr.pdb -package proof.command -package json - -import zio.json.* - -trait Codecs extends proof.json.Codecs: - - given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen - given JsonCodec[CreateProof] = DeriveJsonCodec.gen - given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen - given JsonCodec[UpdateProof] = DeriveJsonCodec.gen - given JsonCodec[RevokeProof] = DeriveJsonCodec.gen diff --git a/domain/proof/command/model/src/Command.scala b/domain/proof/command/model/src/Command.scala index 35a15af..1bff3cd 100644 --- a/domain/proof/command/model/src/Command.scala +++ b/domain/proof/command/model/src/Command.scala @@ -5,7 +5,8 @@ import java.time.LocalDate import java.time.Instant -sealed trait Command +sealed trait Command: + def id: Proof.Id sealed trait AuthorizeOption case object Unauthorized extends AuthorizeOption diff --git a/domain/proof/query/api/src/ProofQueryApi.scala b/domain/proof/query/api/src/ProofQueryApi.scala new file mode 100644 index 0000000..19acd07 --- /dev/null +++ b/domain/proof/query/api/src/ProofQueryApi.scala @@ -0,0 +1,16 @@ +package mdr.pdb.proof.query +package api + +import endpoints.Endpoints +import repo.ProofRepository +import fiftyforms.tapir.CustomTapir +import fiftyforms.tapir.InternalServerError + +object ProofQueryApi extends CustomTapir: + + val forUser: ZServerEndpoint[ProofRepository, Any] = + Endpoints.forUser.zServerLogic(osc => + ProofRepository + .matching(ProofRepository.OfPerson(osc)) + .mapError(InternalServerError.fromThrowable) + ) diff --git a/domain/proof/query/endpoints/src/Endpoints.scala b/domain/proof/query/endpoints/src/Endpoints.scala new file mode 100644 index 0000000..e3d9fb9 --- /dev/null +++ b/domain/proof/query/endpoints/src/Endpoints.scala @@ -0,0 +1,21 @@ +package mdr.pdb +package proof +package query.endpoints + +import fiftyforms.tapir.CustomTapir +import mdr.pdb.proof.codecs.Codecs +import fiftyforms.tapir.ServerError +import zio.json.JsonCodec +import zio.json.DeriveJsonCodec + +object Endpoints + extends mdr.pdb.endpoints.Endpoints + with CustomTapir + with Codecs: + + val forUser: Endpoint[Unit, OsobniCislo, ServerError, Seq[Proof], Any] = + endpoint + .in("proof") + .in(path[OsobniCislo]("osobniCislo")) + .out(jsonBody[Seq[Proof]]) + .errorOut(jsonBody[ServerError]) diff --git a/domain/proof/query/repo/src/MongoProofRepository.scala b/domain/proof/query/repo/src/MongoProofRepository.scala index fad055c..cf19e7a 100644 --- a/domain/proof/query/repo/src/MongoProofRepository.scala +++ b/domain/proof/query/repo/src/MongoProofRepository.scala @@ -9,6 +9,7 @@ import org.bson.json.JsonObject import fiftyforms.mongo.MongoJsonRepository +// TODO: extract common mongo repo config, just nest under mongo / case class MongoProofConfig(db: String, collection: String) object MongoProofConfig { @@ -47,7 +48,7 @@ collection: MongoCollection[JsonObject] ) extends ProofRepositoryWrite: import ProofRepository.* - import mdr.pdb.proof.json.Codecs.given + import mdr.pdb.proof.codecs.Codecs.given private val jsonRepo = MongoJsonRepository[Proof, String, Criteria]( collection, { diff --git a/domain/proof/shared/codecs/src/Codecs.scala b/domain/proof/shared/codecs/src/Codecs.scala new file mode 100644 index 0000000..068f37b --- /dev/null +++ b/domain/proof/shared/codecs/src/Codecs.scala @@ -0,0 +1,20 @@ +package mdr.pdb.proof +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends mdr.pdb.codecs.JsonCodecs: + given JsonCodec[Authorization] = DeriveJsonCodec.gen + given JsonCodec[RevocationReason] = DeriveJsonCodec.gen + given JsonCodec[Revocation] = DeriveJsonCodec.gen + given JsonCodec[Proof] = DeriveJsonCodec.gen + +trait TapirCodecs extends mdr.pdb.codecs.TapirCodecs: + given Schema[Authorization] = Schema.derived + given Schema[RevocationReason] = Schema.derived + given Schema[Revocation] = Schema.derived + given Schema[Proof] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/proof/shared/json/src/Codecs.scala b/domain/proof/shared/json/src/Codecs.scala deleted file mode 100644 index 251550e..0000000 --- a/domain/proof/shared/json/src/Codecs.scala +++ /dev/null @@ -1,13 +0,0 @@ -package mdr.pdb.proof -package json - -import zio.json.* - -trait Codecs extends mdr.pdb.json.Codecs: - - given JsonCodec[Authorization] = DeriveJsonCodec.gen - given JsonCodec[RevocationReason] = DeriveJsonCodec.gen - given JsonCodec[Revocation] = DeriveJsonCodec.gen - given JsonCodec[Proof] = DeriveJsonCodec.gen - -object Codecs extends Codecs diff --git a/domain/users/query/codecs/src/Codecs.scala b/domain/users/query/codecs/src/Codecs.scala new file mode 100644 index 0000000..d30f318 --- /dev/null +++ b/domain/users/query/codecs/src/Codecs.scala @@ -0,0 +1,21 @@ +package mdr.pdb +package users.query +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends mdr.pdb.codecs.JsonCodecs: + given JsonCodec[UserContract] = DeriveJsonCodec.gen + given JsonCodec[UserFunction] = DeriveJsonCodec.gen + given JsonCodec[UserInfo] = DeriveJsonCodec.gen + given JsonCodec[UserProfile] = DeriveJsonCodec.gen + +trait TapirCodecs extends mdr.pdb.codecs.TapirCodecs: + given Schema[UserContract] = Schema.derived + given Schema[UserFunction] = Schema.derived + given Schema[UserInfo] = Schema.derived + given Schema[UserProfile] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/users/query/endpoints/src/Endpoints.scala b/domain/users/query/endpoints/src/Endpoints.scala index de0b074..16bd210 100644 --- a/domain/users/query/endpoints/src/Endpoints.scala +++ b/domain/users/query/endpoints/src/Endpoints.scala @@ -3,7 +3,7 @@ import fiftyforms.tapir.CustomTapir import fiftyforms.tapir.ServerError -import mdr.pdb.users.query.json.Codecs +import mdr.pdb.users.query.codecs.Codecs object Endpoints extends mdr.pdb.endpoints.Endpoints diff --git a/domain/users/query/json/src/Codecs.scala b/domain/users/query/json/src/Codecs.scala deleted file mode 100644 index df7edd1..0000000 --- a/domain/users/query/json/src/Codecs.scala +++ /dev/null @@ -1,14 +0,0 @@ -package mdr.pdb -package users.query -package json - -import zio.json.* - -trait Codecs extends mdr.pdb.json.Codecs: - - given JsonCodec[UserContract] = DeriveJsonCodec.gen - given JsonCodec[UserFunction] = DeriveJsonCodec.gen - given JsonCodec[UserInfo] = DeriveJsonCodec.gen - given JsonCodec[UserProfile] = DeriveJsonCodec.gen - -object Codecs extends Codecs diff --git a/domain/users/query/repo/src/UsersRepository.scala b/domain/users/query/repo/src/UsersRepository.scala index 71b0674..6ae948e 100644 --- a/domain/users/query/repo/src/UsersRepository.scala +++ b/domain/users/query/repo/src/UsersRepository.scala @@ -18,7 +18,7 @@ ZLayer.fromZIO { val readUsers: Task[List[UserInfo]] = import zio.json.{*, given} - import mdr.pdb.users.query.json.Codecs.given + import mdr.pdb.users.query.codecs.Codecs.given for maybeUsers <- readJsonAs( getClass.getResource("/users.json") diff --git a/endpoints/src/main/scala/mdr/pdb/endpoints/Endpoints.scala b/endpoints/src/main/scala/mdr/pdb/endpoints/Endpoints.scala index 7cb36cb..20efe9d 100644 --- a/endpoints/src/main/scala/mdr/pdb/endpoints/Endpoints.scala +++ b/endpoints/src/main/scala/mdr/pdb/endpoints/Endpoints.scala @@ -2,10 +2,10 @@ package endpoints import fiftyforms.tapir.CustomTapir +import sttp.tapir.Codec.PlainCodec +import mdr.pdb.codecs.TapirCodecs -trait Endpoints extends CustomTapir: - given schemaForOsobniCislo: Schema[OsobniCislo] = Schema.string - +trait Endpoints extends CustomTapir with TapirCodecs: val alive: Endpoint[Unit, Unit, Unit, String, Any] = endpoint.in("alive").out(stringBody) diff --git a/fiftyforms/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala b/fiftyforms/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala index eff2357..58a4cfc 100644 --- a/fiftyforms/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala +++ b/fiftyforms/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala @@ -18,11 +18,11 @@ .to[MongoConfig] val fromEnv = ZConfig.fromSystemEnv(configDesc) -val client: RLayer[MongoConfig, MongoClient] = - (for - config <- ZIO.service[MongoConfig] - client <- Task.attempt(MongoClient(config.uri)) - yield client).toLayer +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZIO + .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) + .toLayer class MongoJsonRepository[Elem, Key, Criteria]( collection: MongoCollection[JsonObject], diff --git a/fiftyforms/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala b/fiftyforms/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala index 3882ae6..3603863 100644 --- a/fiftyforms/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala +++ b/fiftyforms/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala @@ -3,13 +3,12 @@ import sttp.tapir.Tapir import sttp.tapir.json.zio.TapirJsonZio import sttp.tapir.TapirAliases -import sttp.tapir.generic.SchemaDerivation trait CustomTapir extends Tapir with TapirJsonZio with TapirAliases - with SchemaDerivation - with CustomTapirPlatformSpecific + with CustomTapirPlatformSpecific: + given Schema[ServerError] = Schema.derived object CustomTapir extends CustomTapir diff --git a/build.sbt b/build.sbt index e6f5e40..227e292 100644 --- a/build.sbt +++ b/build.sbt @@ -14,38 +14,38 @@ lazy val proof = entityProject("proof", file("domain/proof")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .repo(_.dependsOn(`mongo-support`)) .entity(_.dependsOn(`akka-persistence-support`)) .projection(_.dependsOn(`akka-persistence-support`)) - .endpoints(_.dependsOn(`tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val parameters = entityProject("parameters", file("domain/parameters")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) - .endpoints(_.dependsOn(`tapir-support`)) + .codecs(_.dependsOn(codecs, `tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val users = entityProject("users", file("domain/users")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val core = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("core")) -lazy val json = crossProject(JSPlatform, JVMPlatform) +lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) - .in(file("json")) + .in(file("codecs")) .settings(IWDeps.zioJson) - .dependsOn(core) + .dependsOn(core, `tapir-support`) lazy val endpoints = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("endpoints")) - .dependsOn(core, json, `tapir-support`) + .dependsOn(core, codecs, `tapir-support`) // TODO: move all from fiftyforms to iterative works lazy val ui = (project in file("fiftyforms/ui")) @@ -80,7 +80,11 @@ lazy val `akka-persistence-support` = project .in(file("fiftyforms/akka-persistence")) - .settings(IWDeps.akka.profiles.eventsourcedJdbcProjection) + .settings( + IWDeps.akka.libs.persistence, + libraryDependencies += "com.typesafe.akka" %% "akka-cluster-sharding-typed" % IWDeps.akka.V, + IWDeps.akka.profiles.eventsourcedJdbcProjection + ) lazy val app = (project in file("app")) .enablePlugins(ScalaJSPlugin, VitePlugin) @@ -166,6 +170,7 @@ proof.query.api, proof.command.api, proof.query.projection, + proof.command.entity, endpoints.jvm ) diff --git a/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala new file mode 100644 index 0000000..22ebfb8 --- /dev/null +++ b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala @@ -0,0 +1,26 @@ +package mdr.pdb +package codecs + +import zio.json.* +import fiftyforms.tapir.CustomTapir + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + given JsonCodec[WhoWhen] = DeriveJsonCodec.gen + given JsonCodec[OsobniCislo] = + JsonCodec.string.transform(OsobniCislo.apply, _.toString) + given JsonFieldEncoder[OsobniCislo] = + JsonFieldEncoder.string.contramap(_.toString) + given JsonFieldDecoder[OsobniCislo] = + JsonFieldDecoder.string.map(OsobniCislo(_)) + +trait TapirCodecs extends CustomTapir: + given Schema[OsobniCislo] = Schema.string + given Codec.PlainCodec[OsobniCislo] = + Codec.string.mapDecode(OsobniCislo.apply andThen DecodeResult.Value.apply)( + _.toString + ) + given Schema[WhoWhen] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/parameters/shared/codecs/src/Codecs.scala b/domain/parameters/shared/codecs/src/Codecs.scala new file mode 100644 index 0000000..751e79b --- /dev/null +++ b/domain/parameters/shared/codecs/src/Codecs.scala @@ -0,0 +1,10 @@ +package mdr.pdb +package parameters +package codecs + +import zio.json.* + +trait Codecs: + + given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen + given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/parameters/shared/json/src/Codecs.scala b/domain/parameters/shared/json/src/Codecs.scala deleted file mode 100644 index b849072..0000000 --- a/domain/parameters/shared/json/src/Codecs.scala +++ /dev/null @@ -1,10 +0,0 @@ -package mdr.pdb -package parameters -package json - -import zio.json.* - -trait Codecs: - - given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen - given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/proof/command/api/src/ProofCommandApi.scala b/domain/proof/command/api/src/ProofCommandApi.scala new file mode 100644 index 0000000..12f747e --- /dev/null +++ b/domain/proof/command/api/src/ProofCommandApi.scala @@ -0,0 +1,16 @@ +package mdr.pdb.proof.command +package api + +import endpoints.Endpoints +import entity.ProofCommandBus +import fiftyforms.tapir.CustomTapir +import fiftyforms.tapir.InternalServerError + +object ProofCommandApi extends CustomTapir: + + val submitCommand: ZServerEndpoint[ProofCommandBus, Any] = + Endpoints.submitCommand.zServerLogic(cmd => + ProofCommandBus + .submitCommand(cmd) + .mapError(InternalServerError.fromThrowable) + ) diff --git a/domain/proof/command/codecs/src/Codecs.scala b/domain/proof/command/codecs/src/Codecs.scala new file mode 100644 index 0000000..0e75f82 --- /dev/null +++ b/domain/proof/command/codecs/src/Codecs.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof.command +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends proof.codecs.JsonCodecs: + + given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen + given JsonCodec[CreateProof] = DeriveJsonCodec.gen + given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen + given JsonCodec[UpdateProof] = DeriveJsonCodec.gen + given JsonCodec[RevokeProof] = DeriveJsonCodec.gen + given JsonCodec[Command] = DeriveJsonCodec.gen + +trait TapirCodecs extends proof.codecs.TapirCodecs: + + given Schema[AuthorizeOption] = Schema.derived + given Schema[CreateProof] = Schema.derived + given Schema[AuthorizeProof] = Schema.derived + given Schema[UpdateProof] = Schema.derived + given Schema[RevokeProof] = Schema.derived + given Schema[Command] = Schema.derived diff --git a/domain/proof/command/endpoints/src/Endpoints.scala b/domain/proof/command/endpoints/src/Endpoints.scala new file mode 100644 index 0000000..dcd114e --- /dev/null +++ b/domain/proof/command/endpoints/src/Endpoints.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof +package command +package endpoints + +import fiftyforms.tapir.CustomTapir +import mdr.pdb.proof.command.codecs.Codecs +import fiftyforms.tapir.ServerError +import zio.json.JsonCodec +import zio.json.DeriveJsonCodec +import sttp.model.StatusCode + +object Endpoints + extends mdr.pdb.endpoints.Endpoints + with CustomTapir + with Codecs: + + val submitCommand: Endpoint[Unit, Command, ServerError, Unit, Any] = + endpoint + .in("command") + .in("proof") + .post + .in(jsonBody[Command]) + .out(statusCode(StatusCode.Accepted)) + .errorOut(jsonBody[ServerError]) diff --git a/domain/proof/command/entity/src/ProofBehavior.scala b/domain/proof/command/entity/src/ProofBehavior.scala new file mode 100644 index 0000000..f679981 --- /dev/null +++ b/domain/proof/command/entity/src/ProofBehavior.scala @@ -0,0 +1,120 @@ +package mdr.pdb +package proof +package command +package entity + +import akka.persistence.typed.scaladsl.Effect +import akka.persistence.typed.scaladsl.EventSourcedBehavior +import akka.persistence.typed.PersistenceId +import akka.actor.typed.Behavior +import akka.actor.typed.ActorRef +import akka.pattern.StatusReply +import akka.Done + +import fiftyforms.akka.* +import akka.actor.typed.ActorSystem +import akka.cluster.sharding.typed.scaladsl.EntityTypeKey +import akka.cluster.sharding.typed.scaladsl.EntityContext +import akka.cluster.sharding.typed.scaladsl.ClusterSharding +import akka.cluster.sharding.typed.scaladsl.Entity + +object ProofBehavior: + + type ReplyTo = ActorRef[StatusReply[Done]] + + case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) + + type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] + + type ProofReplyEffect = + akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] + + val EntityKey: EntityTypeKey[ProofCommand] = EntityTypeKey("Proof") + + def init(system: ActorSystem[_]): Unit = + val behaviorFactory: EntityContext[ProofCommand] => Behavior[ProofCommand] = + entityContext => ProofBehavior(entityContext.entityId) + ClusterSharding(system).init(Entity(EntityKey)(behaviorFactory)) + + def apply(persistenceId: String): Behavior[ProofCommand] = + import ProofEventHandler.* + EventSourcedBehavior + .withEnforcedReplies[ProofCommand, ProofEvent, State]( + persistenceId = PersistenceId.ofUniqueId(persistenceId), + emptyState = None, + commandHandler = handleProofCommand, + eventHandler = (state, event) => + state.handleEvent(event).orElse(unhandledEvent(event, state)) + ) + .withTagger(_ => Set(ProofEvent.Tag)) + + def handleProofCommand( + state: State, + command: ProofCommand + ): ProofReplyEffect = + handleCommand(state, command.command) match + case Some(events) => persist(events, command.meta, command.replyTo) + case _ => unhandled(command.command, command.replyTo) + + def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = + state match + case None => + cmd match + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + Authorized(note) + ) => + Some( + Seq( + ProofCreated(id, person, parameterId, criterionId, documents), + ProofAuthorized(id, note) + ) + ) + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + _ + ) => + Some( + Seq(ProofCreated(id, person, parameterId, criterionId, documents)) + ) + case _ => None + case Some(proof) if proof.isRevoked => None + case Some(proof) if proof.isAuthorized => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + case Some(proof) => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case UpdateProof(id, documents) => + Some(Seq(ProofUpdated(id, documents))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + + private def persist( + events: Seq[Event], + meta: WW, + replyTo: ReplyTo + ): ProofReplyEffect = + Effect + .persist(events.map(ProofEvent(_, meta))) + .thenReply(replyTo)(_ => StatusReply.Ack) + + private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = + Effect.unhandled.thenReply(replyTo)(s => + StatusReply.error(CommandNotAvailable(command, s)) + ) + + private def unhandledEvent(event: ProofEvent, state: State): Nothing = + throw UnhandledEvent(event, state) diff --git a/domain/proof/command/entity/src/ProofBehaviour.scala b/domain/proof/command/entity/src/ProofBehaviour.scala deleted file mode 100644 index e8c7e4e..0000000 --- a/domain/proof/command/entity/src/ProofBehaviour.scala +++ /dev/null @@ -1,108 +0,0 @@ -package mdr.pdb -package proof -package command -package entity - -import akka.persistence.typed.scaladsl.EventSourcedBehavior -import akka.persistence.typed.PersistenceId -import akka.actor.typed.Behavior -import akka.actor.typed.ActorRef -import akka.pattern.StatusReply -import akka.Done - -import fiftyforms.akka.* -import akka.persistence.typed.scaladsl.Effect - -object ProofBehaviour: - - type ReplyTo = ActorRef[StatusReply[Done]] - - case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) - - type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] - - type ProofReplyEffect = - akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] - - def apply(persistenceId: PersistenceId): Behavior[ProofCommand] = - import ProofEventHandler.* - EventSourcedBehavior - .withEnforcedReplies[ProofCommand, ProofEvent, State]( - persistenceId = persistenceId, - emptyState = None, - commandHandler = handleProofCommand, - eventHandler = (state, event) => - state.handleEvent(event).orElse(unhandledEvent(event, state)) - ) - .withTagger(_ => Set(ProofEvent.Tag)) - - def handleProofCommand( - state: State, - command: ProofCommand - ): ProofReplyEffect = - handleCommand(state, command.command) match - case Some(events) => persist(events, command.meta, command.replyTo) - case _ => unhandled(command.command, command.replyTo) - - def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = - state match - case None => - cmd match - case CreateProof( - id, - person, - parameterId, - criterionId, - documents, - Authorized(note) - ) => - Some( - Seq( - ProofCreated(id, person, parameterId, criterionId, documents), - ProofAuthorized(id, note) - ) - ) - case CreateProof( - id, - person, - parameterId, - criterionId, - documents, - _ - ) => - Some( - Seq(ProofCreated(id, person, parameterId, criterionId, documents)) - ) - case _ => None - case Some(proof) if proof.isRevoked => None - case Some(proof) if proof.isAuthorized => - cmd match - case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) - case RevokeProof(id, reason, since, documents) => - Some(Seq((ProofRevoked(id, reason, since, documents)))) - case _ => None - case Some(proof) => - cmd match - case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) - case UpdateProof(id, documents) => - Some(Seq(ProofUpdated(id, documents))) - case RevokeProof(id, reason, since, documents) => - Some(Seq((ProofRevoked(id, reason, since, documents)))) - case _ => None - - private def persist( - events: Seq[Event], - meta: WW, - replyTo: ReplyTo - ): ProofReplyEffect = - Effect - .persist(events.map(ProofEvent(_, meta))) - .thenReply(replyTo)(_ => StatusReply.Ack) - - private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = - Effect.unhandled.thenReply(replyTo)(s => - StatusReply.error(CommandNotAvailable(command, s)) - ) - - private def unhandledEvent(event: ProofEvent, state: State): Nothing = - throw UnhandledEvent(event, state) diff --git a/domain/proof/command/entity/src/ProofCommandBus.scala b/domain/proof/command/entity/src/ProofCommandBus.scala new file mode 100644 index 0000000..3167e02 --- /dev/null +++ b/domain/proof/command/entity/src/ProofCommandBus.scala @@ -0,0 +1,50 @@ +package mdr.pdb +package proof +package command +package entity + +import zio.* +import akka.actor.typed.ActorSystem +import akka.cluster.sharding.typed.scaladsl.EntityContext +import akka.actor.typed.Behavior +import akka.cluster.sharding.typed.scaladsl.ClusterSharding +import akka.util.Timeout +import java.time.Instant + +object ProofCommandBus: + def submitCommand(command: Command): RIO[ProofCommandBus, Unit] = + ZIO.serviceWithZIO(_.submitCommand(command)) + + val layer: RLayer[ActorSystem[_], ProofCommandBus] = + ZIO + .serviceWithZIO[ActorSystem[_]](system => + for + timeout <- Task.attempt( + Timeout.create( + system.settings.config.getDuration("proof-bus.timeout") + ) + ) + // TODO: init only once + _ <- Task.attempt(ProofBehavior.init(system)) + yield ProofCommandBus(system)(using timeout) + ) + .toLayer + +class ProofCommandBus(system: ActorSystem[_])(using timeout: Timeout): + private val sharding = ClusterSharding(system) + + def submitCommand(command: Command): Task[Unit] = + for + entityRef <- Task.attempt( + sharding.entityRefFor(ProofBehavior.EntityKey, command.id) + ) + reply <- ZIO.fromFuture(_ => + entityRef.askWithStatus( + ProofBehavior.ProofCommand( + command, + WhoWhen(OsobniCislo("0123"), Instant.now()), + _ + ) + ) + ) + yield () diff --git a/domain/proof/command/json/src/Codecs.scala b/domain/proof/command/json/src/Codecs.scala deleted file mode 100644 index 6867615..0000000 --- a/domain/proof/command/json/src/Codecs.scala +++ /dev/null @@ -1,13 +0,0 @@ -package mdr.pdb -package proof.command -package json - -import zio.json.* - -trait Codecs extends proof.json.Codecs: - - given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen - given JsonCodec[CreateProof] = DeriveJsonCodec.gen - given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen - given JsonCodec[UpdateProof] = DeriveJsonCodec.gen - given JsonCodec[RevokeProof] = DeriveJsonCodec.gen diff --git a/domain/proof/command/model/src/Command.scala b/domain/proof/command/model/src/Command.scala index 35a15af..1bff3cd 100644 --- a/domain/proof/command/model/src/Command.scala +++ b/domain/proof/command/model/src/Command.scala @@ -5,7 +5,8 @@ import java.time.LocalDate import java.time.Instant -sealed trait Command +sealed trait Command: + def id: Proof.Id sealed trait AuthorizeOption case object Unauthorized extends AuthorizeOption diff --git a/domain/proof/query/api/src/ProofQueryApi.scala b/domain/proof/query/api/src/ProofQueryApi.scala new file mode 100644 index 0000000..19acd07 --- /dev/null +++ b/domain/proof/query/api/src/ProofQueryApi.scala @@ -0,0 +1,16 @@ +package mdr.pdb.proof.query +package api + +import endpoints.Endpoints +import repo.ProofRepository +import fiftyforms.tapir.CustomTapir +import fiftyforms.tapir.InternalServerError + +object ProofQueryApi extends CustomTapir: + + val forUser: ZServerEndpoint[ProofRepository, Any] = + Endpoints.forUser.zServerLogic(osc => + ProofRepository + .matching(ProofRepository.OfPerson(osc)) + .mapError(InternalServerError.fromThrowable) + ) diff --git a/domain/proof/query/endpoints/src/Endpoints.scala b/domain/proof/query/endpoints/src/Endpoints.scala new file mode 100644 index 0000000..e3d9fb9 --- /dev/null +++ b/domain/proof/query/endpoints/src/Endpoints.scala @@ -0,0 +1,21 @@ +package mdr.pdb +package proof +package query.endpoints + +import fiftyforms.tapir.CustomTapir +import mdr.pdb.proof.codecs.Codecs +import fiftyforms.tapir.ServerError +import zio.json.JsonCodec +import zio.json.DeriveJsonCodec + +object Endpoints + extends mdr.pdb.endpoints.Endpoints + with CustomTapir + with Codecs: + + val forUser: Endpoint[Unit, OsobniCislo, ServerError, Seq[Proof], Any] = + endpoint + .in("proof") + .in(path[OsobniCislo]("osobniCislo")) + .out(jsonBody[Seq[Proof]]) + .errorOut(jsonBody[ServerError]) diff --git a/domain/proof/query/repo/src/MongoProofRepository.scala b/domain/proof/query/repo/src/MongoProofRepository.scala index fad055c..cf19e7a 100644 --- a/domain/proof/query/repo/src/MongoProofRepository.scala +++ b/domain/proof/query/repo/src/MongoProofRepository.scala @@ -9,6 +9,7 @@ import org.bson.json.JsonObject import fiftyforms.mongo.MongoJsonRepository +// TODO: extract common mongo repo config, just nest under mongo / case class MongoProofConfig(db: String, collection: String) object MongoProofConfig { @@ -47,7 +48,7 @@ collection: MongoCollection[JsonObject] ) extends ProofRepositoryWrite: import ProofRepository.* - import mdr.pdb.proof.json.Codecs.given + import mdr.pdb.proof.codecs.Codecs.given private val jsonRepo = MongoJsonRepository[Proof, String, Criteria]( collection, { diff --git a/domain/proof/shared/codecs/src/Codecs.scala b/domain/proof/shared/codecs/src/Codecs.scala new file mode 100644 index 0000000..068f37b --- /dev/null +++ b/domain/proof/shared/codecs/src/Codecs.scala @@ -0,0 +1,20 @@ +package mdr.pdb.proof +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends mdr.pdb.codecs.JsonCodecs: + given JsonCodec[Authorization] = DeriveJsonCodec.gen + given JsonCodec[RevocationReason] = DeriveJsonCodec.gen + given JsonCodec[Revocation] = DeriveJsonCodec.gen + given JsonCodec[Proof] = DeriveJsonCodec.gen + +trait TapirCodecs extends mdr.pdb.codecs.TapirCodecs: + given Schema[Authorization] = Schema.derived + given Schema[RevocationReason] = Schema.derived + given Schema[Revocation] = Schema.derived + given Schema[Proof] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/proof/shared/json/src/Codecs.scala b/domain/proof/shared/json/src/Codecs.scala deleted file mode 100644 index 251550e..0000000 --- a/domain/proof/shared/json/src/Codecs.scala +++ /dev/null @@ -1,13 +0,0 @@ -package mdr.pdb.proof -package json - -import zio.json.* - -trait Codecs extends mdr.pdb.json.Codecs: - - given JsonCodec[Authorization] = DeriveJsonCodec.gen - given JsonCodec[RevocationReason] = DeriveJsonCodec.gen - given JsonCodec[Revocation] = DeriveJsonCodec.gen - given JsonCodec[Proof] = DeriveJsonCodec.gen - -object Codecs extends Codecs diff --git a/domain/users/query/codecs/src/Codecs.scala b/domain/users/query/codecs/src/Codecs.scala new file mode 100644 index 0000000..d30f318 --- /dev/null +++ b/domain/users/query/codecs/src/Codecs.scala @@ -0,0 +1,21 @@ +package mdr.pdb +package users.query +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends mdr.pdb.codecs.JsonCodecs: + given JsonCodec[UserContract] = DeriveJsonCodec.gen + given JsonCodec[UserFunction] = DeriveJsonCodec.gen + given JsonCodec[UserInfo] = DeriveJsonCodec.gen + given JsonCodec[UserProfile] = DeriveJsonCodec.gen + +trait TapirCodecs extends mdr.pdb.codecs.TapirCodecs: + given Schema[UserContract] = Schema.derived + given Schema[UserFunction] = Schema.derived + given Schema[UserInfo] = Schema.derived + given Schema[UserProfile] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/users/query/endpoints/src/Endpoints.scala b/domain/users/query/endpoints/src/Endpoints.scala index de0b074..16bd210 100644 --- a/domain/users/query/endpoints/src/Endpoints.scala +++ b/domain/users/query/endpoints/src/Endpoints.scala @@ -3,7 +3,7 @@ import fiftyforms.tapir.CustomTapir import fiftyforms.tapir.ServerError -import mdr.pdb.users.query.json.Codecs +import mdr.pdb.users.query.codecs.Codecs object Endpoints extends mdr.pdb.endpoints.Endpoints diff --git a/domain/users/query/json/src/Codecs.scala b/domain/users/query/json/src/Codecs.scala deleted file mode 100644 index df7edd1..0000000 --- a/domain/users/query/json/src/Codecs.scala +++ /dev/null @@ -1,14 +0,0 @@ -package mdr.pdb -package users.query -package json - -import zio.json.* - -trait Codecs extends mdr.pdb.json.Codecs: - - given JsonCodec[UserContract] = DeriveJsonCodec.gen - given JsonCodec[UserFunction] = DeriveJsonCodec.gen - given JsonCodec[UserInfo] = DeriveJsonCodec.gen - given JsonCodec[UserProfile] = DeriveJsonCodec.gen - -object Codecs extends Codecs diff --git a/domain/users/query/repo/src/UsersRepository.scala b/domain/users/query/repo/src/UsersRepository.scala index 71b0674..6ae948e 100644 --- a/domain/users/query/repo/src/UsersRepository.scala +++ b/domain/users/query/repo/src/UsersRepository.scala @@ -18,7 +18,7 @@ ZLayer.fromZIO { val readUsers: Task[List[UserInfo]] = import zio.json.{*, given} - import mdr.pdb.users.query.json.Codecs.given + import mdr.pdb.users.query.codecs.Codecs.given for maybeUsers <- readJsonAs( getClass.getResource("/users.json") diff --git a/endpoints/src/main/scala/mdr/pdb/endpoints/Endpoints.scala b/endpoints/src/main/scala/mdr/pdb/endpoints/Endpoints.scala index 7cb36cb..20efe9d 100644 --- a/endpoints/src/main/scala/mdr/pdb/endpoints/Endpoints.scala +++ b/endpoints/src/main/scala/mdr/pdb/endpoints/Endpoints.scala @@ -2,10 +2,10 @@ package endpoints import fiftyforms.tapir.CustomTapir +import sttp.tapir.Codec.PlainCodec +import mdr.pdb.codecs.TapirCodecs -trait Endpoints extends CustomTapir: - given schemaForOsobniCislo: Schema[OsobniCislo] = Schema.string - +trait Endpoints extends CustomTapir with TapirCodecs: val alive: Endpoint[Unit, Unit, Unit, String, Any] = endpoint.in("alive").out(stringBody) diff --git a/fiftyforms/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala b/fiftyforms/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala index eff2357..58a4cfc 100644 --- a/fiftyforms/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala +++ b/fiftyforms/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala @@ -18,11 +18,11 @@ .to[MongoConfig] val fromEnv = ZConfig.fromSystemEnv(configDesc) -val client: RLayer[MongoConfig, MongoClient] = - (for - config <- ZIO.service[MongoConfig] - client <- Task.attempt(MongoClient(config.uri)) - yield client).toLayer +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZIO + .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) + .toLayer class MongoJsonRepository[Elem, Key, Criteria]( collection: MongoCollection[JsonObject], diff --git a/fiftyforms/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala b/fiftyforms/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala index 3882ae6..3603863 100644 --- a/fiftyforms/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala +++ b/fiftyforms/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala @@ -3,13 +3,12 @@ import sttp.tapir.Tapir import sttp.tapir.json.zio.TapirJsonZio import sttp.tapir.TapirAliases -import sttp.tapir.generic.SchemaDerivation trait CustomTapir extends Tapir with TapirJsonZio with TapirAliases - with SchemaDerivation - with CustomTapirPlatformSpecific + with CustomTapirPlatformSpecific: + given Schema[ServerError] = Schema.derived object CustomTapir extends CustomTapir diff --git a/json/src/main/scala/mdr/pdb/json/Codecs.scala b/json/src/main/scala/mdr/pdb/json/Codecs.scala deleted file mode 100644 index 31c411f..0000000 --- a/json/src/main/scala/mdr/pdb/json/Codecs.scala +++ /dev/null @@ -1,16 +0,0 @@ -package mdr.pdb -package json - -import zio.json.* - -trait Codecs: - - given JsonCodec[WhoWhen] = DeriveJsonCodec.gen - given JsonCodec[OsobniCislo] = - JsonCodec.string.transform(OsobniCislo.apply, _.toString) - given JsonFieldEncoder[OsobniCislo] = - JsonFieldEncoder.string.contramap(_.toString) - given JsonFieldDecoder[OsobniCislo] = - JsonFieldDecoder.string.map(OsobniCislo(_)) - -object Codecs extends Codecs diff --git a/build.sbt b/build.sbt index e6f5e40..227e292 100644 --- a/build.sbt +++ b/build.sbt @@ -14,38 +14,38 @@ lazy val proof = entityProject("proof", file("domain/proof")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .repo(_.dependsOn(`mongo-support`)) .entity(_.dependsOn(`akka-persistence-support`)) .projection(_.dependsOn(`akka-persistence-support`)) - .endpoints(_.dependsOn(`tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val parameters = entityProject("parameters", file("domain/parameters")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) - .endpoints(_.dependsOn(`tapir-support`)) + .codecs(_.dependsOn(codecs, `tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val users = entityProject("users", file("domain/users")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val core = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("core")) -lazy val json = crossProject(JSPlatform, JVMPlatform) +lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) - .in(file("json")) + .in(file("codecs")) .settings(IWDeps.zioJson) - .dependsOn(core) + .dependsOn(core, `tapir-support`) lazy val endpoints = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("endpoints")) - .dependsOn(core, json, `tapir-support`) + .dependsOn(core, codecs, `tapir-support`) // TODO: move all from fiftyforms to iterative works lazy val ui = (project in file("fiftyforms/ui")) @@ -80,7 +80,11 @@ lazy val `akka-persistence-support` = project .in(file("fiftyforms/akka-persistence")) - .settings(IWDeps.akka.profiles.eventsourcedJdbcProjection) + .settings( + IWDeps.akka.libs.persistence, + libraryDependencies += "com.typesafe.akka" %% "akka-cluster-sharding-typed" % IWDeps.akka.V, + IWDeps.akka.profiles.eventsourcedJdbcProjection + ) lazy val app = (project in file("app")) .enablePlugins(ScalaJSPlugin, VitePlugin) @@ -166,6 +170,7 @@ proof.query.api, proof.command.api, proof.query.projection, + proof.command.entity, endpoints.jvm ) diff --git a/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala new file mode 100644 index 0000000..22ebfb8 --- /dev/null +++ b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala @@ -0,0 +1,26 @@ +package mdr.pdb +package codecs + +import zio.json.* +import fiftyforms.tapir.CustomTapir + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + given JsonCodec[WhoWhen] = DeriveJsonCodec.gen + given JsonCodec[OsobniCislo] = + JsonCodec.string.transform(OsobniCislo.apply, _.toString) + given JsonFieldEncoder[OsobniCislo] = + JsonFieldEncoder.string.contramap(_.toString) + given JsonFieldDecoder[OsobniCislo] = + JsonFieldDecoder.string.map(OsobniCislo(_)) + +trait TapirCodecs extends CustomTapir: + given Schema[OsobniCislo] = Schema.string + given Codec.PlainCodec[OsobniCislo] = + Codec.string.mapDecode(OsobniCislo.apply andThen DecodeResult.Value.apply)( + _.toString + ) + given Schema[WhoWhen] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/parameters/shared/codecs/src/Codecs.scala b/domain/parameters/shared/codecs/src/Codecs.scala new file mode 100644 index 0000000..751e79b --- /dev/null +++ b/domain/parameters/shared/codecs/src/Codecs.scala @@ -0,0 +1,10 @@ +package mdr.pdb +package parameters +package codecs + +import zio.json.* + +trait Codecs: + + given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen + given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/parameters/shared/json/src/Codecs.scala b/domain/parameters/shared/json/src/Codecs.scala deleted file mode 100644 index b849072..0000000 --- a/domain/parameters/shared/json/src/Codecs.scala +++ /dev/null @@ -1,10 +0,0 @@ -package mdr.pdb -package parameters -package json - -import zio.json.* - -trait Codecs: - - given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen - given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/proof/command/api/src/ProofCommandApi.scala b/domain/proof/command/api/src/ProofCommandApi.scala new file mode 100644 index 0000000..12f747e --- /dev/null +++ b/domain/proof/command/api/src/ProofCommandApi.scala @@ -0,0 +1,16 @@ +package mdr.pdb.proof.command +package api + +import endpoints.Endpoints +import entity.ProofCommandBus +import fiftyforms.tapir.CustomTapir +import fiftyforms.tapir.InternalServerError + +object ProofCommandApi extends CustomTapir: + + val submitCommand: ZServerEndpoint[ProofCommandBus, Any] = + Endpoints.submitCommand.zServerLogic(cmd => + ProofCommandBus + .submitCommand(cmd) + .mapError(InternalServerError.fromThrowable) + ) diff --git a/domain/proof/command/codecs/src/Codecs.scala b/domain/proof/command/codecs/src/Codecs.scala new file mode 100644 index 0000000..0e75f82 --- /dev/null +++ b/domain/proof/command/codecs/src/Codecs.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof.command +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends proof.codecs.JsonCodecs: + + given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen + given JsonCodec[CreateProof] = DeriveJsonCodec.gen + given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen + given JsonCodec[UpdateProof] = DeriveJsonCodec.gen + given JsonCodec[RevokeProof] = DeriveJsonCodec.gen + given JsonCodec[Command] = DeriveJsonCodec.gen + +trait TapirCodecs extends proof.codecs.TapirCodecs: + + given Schema[AuthorizeOption] = Schema.derived + given Schema[CreateProof] = Schema.derived + given Schema[AuthorizeProof] = Schema.derived + given Schema[UpdateProof] = Schema.derived + given Schema[RevokeProof] = Schema.derived + given Schema[Command] = Schema.derived diff --git a/domain/proof/command/endpoints/src/Endpoints.scala b/domain/proof/command/endpoints/src/Endpoints.scala new file mode 100644 index 0000000..dcd114e --- /dev/null +++ b/domain/proof/command/endpoints/src/Endpoints.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof +package command +package endpoints + +import fiftyforms.tapir.CustomTapir +import mdr.pdb.proof.command.codecs.Codecs +import fiftyforms.tapir.ServerError +import zio.json.JsonCodec +import zio.json.DeriveJsonCodec +import sttp.model.StatusCode + +object Endpoints + extends mdr.pdb.endpoints.Endpoints + with CustomTapir + with Codecs: + + val submitCommand: Endpoint[Unit, Command, ServerError, Unit, Any] = + endpoint + .in("command") + .in("proof") + .post + .in(jsonBody[Command]) + .out(statusCode(StatusCode.Accepted)) + .errorOut(jsonBody[ServerError]) diff --git a/domain/proof/command/entity/src/ProofBehavior.scala b/domain/proof/command/entity/src/ProofBehavior.scala new file mode 100644 index 0000000..f679981 --- /dev/null +++ b/domain/proof/command/entity/src/ProofBehavior.scala @@ -0,0 +1,120 @@ +package mdr.pdb +package proof +package command +package entity + +import akka.persistence.typed.scaladsl.Effect +import akka.persistence.typed.scaladsl.EventSourcedBehavior +import akka.persistence.typed.PersistenceId +import akka.actor.typed.Behavior +import akka.actor.typed.ActorRef +import akka.pattern.StatusReply +import akka.Done + +import fiftyforms.akka.* +import akka.actor.typed.ActorSystem +import akka.cluster.sharding.typed.scaladsl.EntityTypeKey +import akka.cluster.sharding.typed.scaladsl.EntityContext +import akka.cluster.sharding.typed.scaladsl.ClusterSharding +import akka.cluster.sharding.typed.scaladsl.Entity + +object ProofBehavior: + + type ReplyTo = ActorRef[StatusReply[Done]] + + case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) + + type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] + + type ProofReplyEffect = + akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] + + val EntityKey: EntityTypeKey[ProofCommand] = EntityTypeKey("Proof") + + def init(system: ActorSystem[_]): Unit = + val behaviorFactory: EntityContext[ProofCommand] => Behavior[ProofCommand] = + entityContext => ProofBehavior(entityContext.entityId) + ClusterSharding(system).init(Entity(EntityKey)(behaviorFactory)) + + def apply(persistenceId: String): Behavior[ProofCommand] = + import ProofEventHandler.* + EventSourcedBehavior + .withEnforcedReplies[ProofCommand, ProofEvent, State]( + persistenceId = PersistenceId.ofUniqueId(persistenceId), + emptyState = None, + commandHandler = handleProofCommand, + eventHandler = (state, event) => + state.handleEvent(event).orElse(unhandledEvent(event, state)) + ) + .withTagger(_ => Set(ProofEvent.Tag)) + + def handleProofCommand( + state: State, + command: ProofCommand + ): ProofReplyEffect = + handleCommand(state, command.command) match + case Some(events) => persist(events, command.meta, command.replyTo) + case _ => unhandled(command.command, command.replyTo) + + def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = + state match + case None => + cmd match + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + Authorized(note) + ) => + Some( + Seq( + ProofCreated(id, person, parameterId, criterionId, documents), + ProofAuthorized(id, note) + ) + ) + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + _ + ) => + Some( + Seq(ProofCreated(id, person, parameterId, criterionId, documents)) + ) + case _ => None + case Some(proof) if proof.isRevoked => None + case Some(proof) if proof.isAuthorized => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + case Some(proof) => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case UpdateProof(id, documents) => + Some(Seq(ProofUpdated(id, documents))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + + private def persist( + events: Seq[Event], + meta: WW, + replyTo: ReplyTo + ): ProofReplyEffect = + Effect + .persist(events.map(ProofEvent(_, meta))) + .thenReply(replyTo)(_ => StatusReply.Ack) + + private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = + Effect.unhandled.thenReply(replyTo)(s => + StatusReply.error(CommandNotAvailable(command, s)) + ) + + private def unhandledEvent(event: ProofEvent, state: State): Nothing = + throw UnhandledEvent(event, state) diff --git a/domain/proof/command/entity/src/ProofBehaviour.scala b/domain/proof/command/entity/src/ProofBehaviour.scala deleted file mode 100644 index e8c7e4e..0000000 --- a/domain/proof/command/entity/src/ProofBehaviour.scala +++ /dev/null @@ -1,108 +0,0 @@ -package mdr.pdb -package proof -package command -package entity - -import akka.persistence.typed.scaladsl.EventSourcedBehavior -import akka.persistence.typed.PersistenceId -import akka.actor.typed.Behavior -import akka.actor.typed.ActorRef -import akka.pattern.StatusReply -import akka.Done - -import fiftyforms.akka.* -import akka.persistence.typed.scaladsl.Effect - -object ProofBehaviour: - - type ReplyTo = ActorRef[StatusReply[Done]] - - case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) - - type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] - - type ProofReplyEffect = - akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] - - def apply(persistenceId: PersistenceId): Behavior[ProofCommand] = - import ProofEventHandler.* - EventSourcedBehavior - .withEnforcedReplies[ProofCommand, ProofEvent, State]( - persistenceId = persistenceId, - emptyState = None, - commandHandler = handleProofCommand, - eventHandler = (state, event) => - state.handleEvent(event).orElse(unhandledEvent(event, state)) - ) - .withTagger(_ => Set(ProofEvent.Tag)) - - def handleProofCommand( - state: State, - command: ProofCommand - ): ProofReplyEffect = - handleCommand(state, command.command) match - case Some(events) => persist(events, command.meta, command.replyTo) - case _ => unhandled(command.command, command.replyTo) - - def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = - state match - case None => - cmd match - case CreateProof( - id, - person, - parameterId, - criterionId, - documents, - Authorized(note) - ) => - Some( - Seq( - ProofCreated(id, person, parameterId, criterionId, documents), - ProofAuthorized(id, note) - ) - ) - case CreateProof( - id, - person, - parameterId, - criterionId, - documents, - _ - ) => - Some( - Seq(ProofCreated(id, person, parameterId, criterionId, documents)) - ) - case _ => None - case Some(proof) if proof.isRevoked => None - case Some(proof) if proof.isAuthorized => - cmd match - case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) - case RevokeProof(id, reason, since, documents) => - Some(Seq((ProofRevoked(id, reason, since, documents)))) - case _ => None - case Some(proof) => - cmd match - case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) - case UpdateProof(id, documents) => - Some(Seq(ProofUpdated(id, documents))) - case RevokeProof(id, reason, since, documents) => - Some(Seq((ProofRevoked(id, reason, since, documents)))) - case _ => None - - private def persist( - events: Seq[Event], - meta: WW, - replyTo: ReplyTo - ): ProofReplyEffect = - Effect - .persist(events.map(ProofEvent(_, meta))) - .thenReply(replyTo)(_ => StatusReply.Ack) - - private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = - Effect.unhandled.thenReply(replyTo)(s => - StatusReply.error(CommandNotAvailable(command, s)) - ) - - private def unhandledEvent(event: ProofEvent, state: State): Nothing = - throw UnhandledEvent(event, state) diff --git a/domain/proof/command/entity/src/ProofCommandBus.scala b/domain/proof/command/entity/src/ProofCommandBus.scala new file mode 100644 index 0000000..3167e02 --- /dev/null +++ b/domain/proof/command/entity/src/ProofCommandBus.scala @@ -0,0 +1,50 @@ +package mdr.pdb +package proof +package command +package entity + +import zio.* +import akka.actor.typed.ActorSystem +import akka.cluster.sharding.typed.scaladsl.EntityContext +import akka.actor.typed.Behavior +import akka.cluster.sharding.typed.scaladsl.ClusterSharding +import akka.util.Timeout +import java.time.Instant + +object ProofCommandBus: + def submitCommand(command: Command): RIO[ProofCommandBus, Unit] = + ZIO.serviceWithZIO(_.submitCommand(command)) + + val layer: RLayer[ActorSystem[_], ProofCommandBus] = + ZIO + .serviceWithZIO[ActorSystem[_]](system => + for + timeout <- Task.attempt( + Timeout.create( + system.settings.config.getDuration("proof-bus.timeout") + ) + ) + // TODO: init only once + _ <- Task.attempt(ProofBehavior.init(system)) + yield ProofCommandBus(system)(using timeout) + ) + .toLayer + +class ProofCommandBus(system: ActorSystem[_])(using timeout: Timeout): + private val sharding = ClusterSharding(system) + + def submitCommand(command: Command): Task[Unit] = + for + entityRef <- Task.attempt( + sharding.entityRefFor(ProofBehavior.EntityKey, command.id) + ) + reply <- ZIO.fromFuture(_ => + entityRef.askWithStatus( + ProofBehavior.ProofCommand( + command, + WhoWhen(OsobniCislo("0123"), Instant.now()), + _ + ) + ) + ) + yield () diff --git a/domain/proof/command/json/src/Codecs.scala b/domain/proof/command/json/src/Codecs.scala deleted file mode 100644 index 6867615..0000000 --- a/domain/proof/command/json/src/Codecs.scala +++ /dev/null @@ -1,13 +0,0 @@ -package mdr.pdb -package proof.command -package json - -import zio.json.* - -trait Codecs extends proof.json.Codecs: - - given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen - given JsonCodec[CreateProof] = DeriveJsonCodec.gen - given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen - given JsonCodec[UpdateProof] = DeriveJsonCodec.gen - given JsonCodec[RevokeProof] = DeriveJsonCodec.gen diff --git a/domain/proof/command/model/src/Command.scala b/domain/proof/command/model/src/Command.scala index 35a15af..1bff3cd 100644 --- a/domain/proof/command/model/src/Command.scala +++ b/domain/proof/command/model/src/Command.scala @@ -5,7 +5,8 @@ import java.time.LocalDate import java.time.Instant -sealed trait Command +sealed trait Command: + def id: Proof.Id sealed trait AuthorizeOption case object Unauthorized extends AuthorizeOption diff --git a/domain/proof/query/api/src/ProofQueryApi.scala b/domain/proof/query/api/src/ProofQueryApi.scala new file mode 100644 index 0000000..19acd07 --- /dev/null +++ b/domain/proof/query/api/src/ProofQueryApi.scala @@ -0,0 +1,16 @@ +package mdr.pdb.proof.query +package api + +import endpoints.Endpoints +import repo.ProofRepository +import fiftyforms.tapir.CustomTapir +import fiftyforms.tapir.InternalServerError + +object ProofQueryApi extends CustomTapir: + + val forUser: ZServerEndpoint[ProofRepository, Any] = + Endpoints.forUser.zServerLogic(osc => + ProofRepository + .matching(ProofRepository.OfPerson(osc)) + .mapError(InternalServerError.fromThrowable) + ) diff --git a/domain/proof/query/endpoints/src/Endpoints.scala b/domain/proof/query/endpoints/src/Endpoints.scala new file mode 100644 index 0000000..e3d9fb9 --- /dev/null +++ b/domain/proof/query/endpoints/src/Endpoints.scala @@ -0,0 +1,21 @@ +package mdr.pdb +package proof +package query.endpoints + +import fiftyforms.tapir.CustomTapir +import mdr.pdb.proof.codecs.Codecs +import fiftyforms.tapir.ServerError +import zio.json.JsonCodec +import zio.json.DeriveJsonCodec + +object Endpoints + extends mdr.pdb.endpoints.Endpoints + with CustomTapir + with Codecs: + + val forUser: Endpoint[Unit, OsobniCislo, ServerError, Seq[Proof], Any] = + endpoint + .in("proof") + .in(path[OsobniCislo]("osobniCislo")) + .out(jsonBody[Seq[Proof]]) + .errorOut(jsonBody[ServerError]) diff --git a/domain/proof/query/repo/src/MongoProofRepository.scala b/domain/proof/query/repo/src/MongoProofRepository.scala index fad055c..cf19e7a 100644 --- a/domain/proof/query/repo/src/MongoProofRepository.scala +++ b/domain/proof/query/repo/src/MongoProofRepository.scala @@ -9,6 +9,7 @@ import org.bson.json.JsonObject import fiftyforms.mongo.MongoJsonRepository +// TODO: extract common mongo repo config, just nest under mongo / case class MongoProofConfig(db: String, collection: String) object MongoProofConfig { @@ -47,7 +48,7 @@ collection: MongoCollection[JsonObject] ) extends ProofRepositoryWrite: import ProofRepository.* - import mdr.pdb.proof.json.Codecs.given + import mdr.pdb.proof.codecs.Codecs.given private val jsonRepo = MongoJsonRepository[Proof, String, Criteria]( collection, { diff --git a/domain/proof/shared/codecs/src/Codecs.scala b/domain/proof/shared/codecs/src/Codecs.scala new file mode 100644 index 0000000..068f37b --- /dev/null +++ b/domain/proof/shared/codecs/src/Codecs.scala @@ -0,0 +1,20 @@ +package mdr.pdb.proof +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends mdr.pdb.codecs.JsonCodecs: + given JsonCodec[Authorization] = DeriveJsonCodec.gen + given JsonCodec[RevocationReason] = DeriveJsonCodec.gen + given JsonCodec[Revocation] = DeriveJsonCodec.gen + given JsonCodec[Proof] = DeriveJsonCodec.gen + +trait TapirCodecs extends mdr.pdb.codecs.TapirCodecs: + given Schema[Authorization] = Schema.derived + given Schema[RevocationReason] = Schema.derived + given Schema[Revocation] = Schema.derived + given Schema[Proof] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/proof/shared/json/src/Codecs.scala b/domain/proof/shared/json/src/Codecs.scala deleted file mode 100644 index 251550e..0000000 --- a/domain/proof/shared/json/src/Codecs.scala +++ /dev/null @@ -1,13 +0,0 @@ -package mdr.pdb.proof -package json - -import zio.json.* - -trait Codecs extends mdr.pdb.json.Codecs: - - given JsonCodec[Authorization] = DeriveJsonCodec.gen - given JsonCodec[RevocationReason] = DeriveJsonCodec.gen - given JsonCodec[Revocation] = DeriveJsonCodec.gen - given JsonCodec[Proof] = DeriveJsonCodec.gen - -object Codecs extends Codecs diff --git a/domain/users/query/codecs/src/Codecs.scala b/domain/users/query/codecs/src/Codecs.scala new file mode 100644 index 0000000..d30f318 --- /dev/null +++ b/domain/users/query/codecs/src/Codecs.scala @@ -0,0 +1,21 @@ +package mdr.pdb +package users.query +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends mdr.pdb.codecs.JsonCodecs: + given JsonCodec[UserContract] = DeriveJsonCodec.gen + given JsonCodec[UserFunction] = DeriveJsonCodec.gen + given JsonCodec[UserInfo] = DeriveJsonCodec.gen + given JsonCodec[UserProfile] = DeriveJsonCodec.gen + +trait TapirCodecs extends mdr.pdb.codecs.TapirCodecs: + given Schema[UserContract] = Schema.derived + given Schema[UserFunction] = Schema.derived + given Schema[UserInfo] = Schema.derived + given Schema[UserProfile] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/users/query/endpoints/src/Endpoints.scala b/domain/users/query/endpoints/src/Endpoints.scala index de0b074..16bd210 100644 --- a/domain/users/query/endpoints/src/Endpoints.scala +++ b/domain/users/query/endpoints/src/Endpoints.scala @@ -3,7 +3,7 @@ import fiftyforms.tapir.CustomTapir import fiftyforms.tapir.ServerError -import mdr.pdb.users.query.json.Codecs +import mdr.pdb.users.query.codecs.Codecs object Endpoints extends mdr.pdb.endpoints.Endpoints diff --git a/domain/users/query/json/src/Codecs.scala b/domain/users/query/json/src/Codecs.scala deleted file mode 100644 index df7edd1..0000000 --- a/domain/users/query/json/src/Codecs.scala +++ /dev/null @@ -1,14 +0,0 @@ -package mdr.pdb -package users.query -package json - -import zio.json.* - -trait Codecs extends mdr.pdb.json.Codecs: - - given JsonCodec[UserContract] = DeriveJsonCodec.gen - given JsonCodec[UserFunction] = DeriveJsonCodec.gen - given JsonCodec[UserInfo] = DeriveJsonCodec.gen - given JsonCodec[UserProfile] = DeriveJsonCodec.gen - -object Codecs extends Codecs diff --git a/domain/users/query/repo/src/UsersRepository.scala b/domain/users/query/repo/src/UsersRepository.scala index 71b0674..6ae948e 100644 --- a/domain/users/query/repo/src/UsersRepository.scala +++ b/domain/users/query/repo/src/UsersRepository.scala @@ -18,7 +18,7 @@ ZLayer.fromZIO { val readUsers: Task[List[UserInfo]] = import zio.json.{*, given} - import mdr.pdb.users.query.json.Codecs.given + import mdr.pdb.users.query.codecs.Codecs.given for maybeUsers <- readJsonAs( getClass.getResource("/users.json") diff --git a/endpoints/src/main/scala/mdr/pdb/endpoints/Endpoints.scala b/endpoints/src/main/scala/mdr/pdb/endpoints/Endpoints.scala index 7cb36cb..20efe9d 100644 --- a/endpoints/src/main/scala/mdr/pdb/endpoints/Endpoints.scala +++ b/endpoints/src/main/scala/mdr/pdb/endpoints/Endpoints.scala @@ -2,10 +2,10 @@ package endpoints import fiftyforms.tapir.CustomTapir +import sttp.tapir.Codec.PlainCodec +import mdr.pdb.codecs.TapirCodecs -trait Endpoints extends CustomTapir: - given schemaForOsobniCislo: Schema[OsobniCislo] = Schema.string - +trait Endpoints extends CustomTapir with TapirCodecs: val alive: Endpoint[Unit, Unit, Unit, String, Any] = endpoint.in("alive").out(stringBody) diff --git a/fiftyforms/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala b/fiftyforms/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala index eff2357..58a4cfc 100644 --- a/fiftyforms/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala +++ b/fiftyforms/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala @@ -18,11 +18,11 @@ .to[MongoConfig] val fromEnv = ZConfig.fromSystemEnv(configDesc) -val client: RLayer[MongoConfig, MongoClient] = - (for - config <- ZIO.service[MongoConfig] - client <- Task.attempt(MongoClient(config.uri)) - yield client).toLayer +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZIO + .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) + .toLayer class MongoJsonRepository[Elem, Key, Criteria]( collection: MongoCollection[JsonObject], diff --git a/fiftyforms/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala b/fiftyforms/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala index 3882ae6..3603863 100644 --- a/fiftyforms/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala +++ b/fiftyforms/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala @@ -3,13 +3,12 @@ import sttp.tapir.Tapir import sttp.tapir.json.zio.TapirJsonZio import sttp.tapir.TapirAliases -import sttp.tapir.generic.SchemaDerivation trait CustomTapir extends Tapir with TapirJsonZio with TapirAliases - with SchemaDerivation - with CustomTapirPlatformSpecific + with CustomTapirPlatformSpecific: + given Schema[ServerError] = Schema.derived object CustomTapir extends CustomTapir diff --git a/json/src/main/scala/mdr/pdb/json/Codecs.scala b/json/src/main/scala/mdr/pdb/json/Codecs.scala deleted file mode 100644 index 31c411f..0000000 --- a/json/src/main/scala/mdr/pdb/json/Codecs.scala +++ /dev/null @@ -1,16 +0,0 @@ -package mdr.pdb -package json - -import zio.json.* - -trait Codecs: - - given JsonCodec[WhoWhen] = DeriveJsonCodec.gen - given JsonCodec[OsobniCislo] = - JsonCodec.string.transform(OsobniCislo.apply, _.toString) - given JsonFieldEncoder[OsobniCislo] = - JsonFieldEncoder.string.contramap(_.toString) - given JsonFieldDecoder[OsobniCislo] = - JsonFieldDecoder.string.map(OsobniCislo(_)) - -object Codecs extends Codecs diff --git a/project/DomainProjectsPlugin.scala b/project/DomainProjectsPlugin.scala index 03941e8..5e87014 100644 --- a/project/DomainProjectsPlugin.scala +++ b/project/DomainProjectsPlugin.scala @@ -21,14 +21,14 @@ case class EntityProject( model: CrossProject, - json: CrossProject, + codecs: CrossProject, query: QueryProjects, command: CommandProjects ) extends CompositeProject { def model(upd: CrossProject => CrossProject): EntityProject = copy(model = upd(model)) - def json(upd: CrossProject => CrossProject): EntityProject = - copy(json = upd(json)) + def codecs(upd: CrossProject => CrossProject): EntityProject = + copy(codecs = upd(codecs)) def query(upd: QueryProjects => QueryProjects): EntityProject = copy(query = upd(query)) def command(upd: CommandProjects => CommandProjects): EntityProject = @@ -43,14 +43,14 @@ ) def entity(upd: Project => Project): EntityProject = command(_.entity(upd)) override def componentProjects: Seq[Project] = - Seq(model, json).flatMap( + Seq(model, codecs).flatMap( _.componentProjects ) ++ query.componentProjects ++ command.componentProjects } case class CommonProjects( model: CrossProject, - json: CrossProject, + codecs: CrossProject, endpoints: CrossProject, client: Project, api: Project, @@ -58,8 +58,8 @@ ) extends CompositeProject { def model(upd: CrossProject => CrossProject): CommonProjects = copy(model = upd(model)) - def json(upd: CrossProject => CrossProject): CommonProjects = - copy(json = upd(json)) + def codecs(upd: CrossProject => CrossProject): CommonProjects = + copy(codecs = upd(codecs)) def endpoints(upd: CrossProject => CrossProject): CommonProjects = copy(endpoints = upd(endpoints)) def client(upd: Project => Project): CommonProjects = @@ -68,7 +68,7 @@ def components(upd: Project => Project): CommonProjects = copy(components = upd(components)) override def componentProjects: Seq[Project] = - Seq(model, json, endpoints).flatMap( + Seq(model, codecs, endpoints).flatMap( _.componentProjects ) ++ Seq(client, api, components) } @@ -81,9 +81,9 @@ val model = common.model def model(upd: CrossProject => CrossProject): QueryProjects = copy(common = common.model(upd)) - val json = common.json - def json(upd: CrossProject => CrossProject): QueryProjects = - copy(common = common.json(upd)) + val codecs = common.codecs + def codecs(upd: CrossProject => CrossProject): QueryProjects = + copy(common = common.codecs(upd)) val endpoints = common.endpoints def endpoints(upd: CrossProject => CrossProject): QueryProjects = copy(common = common.endpoints(upd)) @@ -108,9 +108,9 @@ val model = common.model def model(upd: CrossProject => CrossProject): CommandProjects = copy(common = common.model(upd)) - val json = common.json - def json(upd: CrossProject => CrossProject): CommandProjects = - copy(common = common.json(upd)) + val codecs = common.codecs + def codecs(upd: CrossProject => CrossProject): CommandProjects = + copy(common = common.codecs(upd)) val endpoints = common.endpoints def endpoints(upd: CrossProject => CrossProject): CommandProjects = copy(common = common.endpoints(upd)) @@ -185,20 +185,20 @@ def pb(kind: String) = new ProjectBuilder(b, base)(kind) val sh = pb("shared") val sharedModel = sh.cp("model").settings(IWDeps.zioPrelude) - val sharedJson = - sh.cp("json").settings(IWDeps.zioJson).dependsOn(sharedModel) + val sharedCodecs = + sh.cp("codecs").settings(IWDeps.zioJson).dependsOn(sharedModel) def commonProjects(kb: ProjectBuilder) = { import kb._ val model: CrossProject = cp("model").dependsOn(sharedModel) - val json: CrossProject = - cp("json").dependsOn(model, sharedJson) + val codecs: CrossProject = + cp("codecs").dependsOn(model, sharedCodecs) val endpoints: CrossProject = cp("endpoints") .settings( IWDeps.tapirCore, IWDeps.tapirZIOJson ) - .dependsOn(model, json) + .dependsOn(model, codecs) val client: Project = js("client").dependsOn(endpoints.projects(JSPlatform)) val api: Project = p("api") @@ -215,7 +215,7 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - CommonProjects(model, json, endpoints, client, api, components) + CommonProjects(model, codecs, endpoints, client, api, components) } def queryProjects = { @@ -226,7 +226,7 @@ .settings(IWDeps.useZIO(Test)) .dependsOn( common.model.projects(JVMPlatform), - common.json.projects(JVMPlatform) + common.codecs.projects(JVMPlatform) ) QueryProjects( common.api(_.dependsOn(repo)), @@ -239,18 +239,20 @@ val cb = pb("command") import cb._ val common = commonProjects(cb) - CommandProjects( - common, + val entity = p("entity").dependsOn( common.model.projects(JVMPlatform), - common.json.projects(JVMPlatform) + common.codecs.projects(JVMPlatform) ) + CommandProjects( + common.api(_.dependsOn(entity)), + entity ) } EntityProject( model = sharedModel, - json = sharedJson, + codecs = sharedCodecs, query = queryProjects, command = commandProjects ) diff --git a/build.sbt b/build.sbt index e6f5e40..227e292 100644 --- a/build.sbt +++ b/build.sbt @@ -14,38 +14,38 @@ lazy val proof = entityProject("proof", file("domain/proof")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .repo(_.dependsOn(`mongo-support`)) .entity(_.dependsOn(`akka-persistence-support`)) .projection(_.dependsOn(`akka-persistence-support`)) - .endpoints(_.dependsOn(`tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val parameters = entityProject("parameters", file("domain/parameters")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) - .endpoints(_.dependsOn(`tapir-support`)) + .codecs(_.dependsOn(codecs, `tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val users = entityProject("users", file("domain/users")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val core = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("core")) -lazy val json = crossProject(JSPlatform, JVMPlatform) +lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) - .in(file("json")) + .in(file("codecs")) .settings(IWDeps.zioJson) - .dependsOn(core) + .dependsOn(core, `tapir-support`) lazy val endpoints = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("endpoints")) - .dependsOn(core, json, `tapir-support`) + .dependsOn(core, codecs, `tapir-support`) // TODO: move all from fiftyforms to iterative works lazy val ui = (project in file("fiftyforms/ui")) @@ -80,7 +80,11 @@ lazy val `akka-persistence-support` = project .in(file("fiftyforms/akka-persistence")) - .settings(IWDeps.akka.profiles.eventsourcedJdbcProjection) + .settings( + IWDeps.akka.libs.persistence, + libraryDependencies += "com.typesafe.akka" %% "akka-cluster-sharding-typed" % IWDeps.akka.V, + IWDeps.akka.profiles.eventsourcedJdbcProjection + ) lazy val app = (project in file("app")) .enablePlugins(ScalaJSPlugin, VitePlugin) @@ -166,6 +170,7 @@ proof.query.api, proof.command.api, proof.query.projection, + proof.command.entity, endpoints.jvm ) diff --git a/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala new file mode 100644 index 0000000..22ebfb8 --- /dev/null +++ b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala @@ -0,0 +1,26 @@ +package mdr.pdb +package codecs + +import zio.json.* +import fiftyforms.tapir.CustomTapir + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + given JsonCodec[WhoWhen] = DeriveJsonCodec.gen + given JsonCodec[OsobniCislo] = + JsonCodec.string.transform(OsobniCislo.apply, _.toString) + given JsonFieldEncoder[OsobniCislo] = + JsonFieldEncoder.string.contramap(_.toString) + given JsonFieldDecoder[OsobniCislo] = + JsonFieldDecoder.string.map(OsobniCislo(_)) + +trait TapirCodecs extends CustomTapir: + given Schema[OsobniCislo] = Schema.string + given Codec.PlainCodec[OsobniCislo] = + Codec.string.mapDecode(OsobniCislo.apply andThen DecodeResult.Value.apply)( + _.toString + ) + given Schema[WhoWhen] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/parameters/shared/codecs/src/Codecs.scala b/domain/parameters/shared/codecs/src/Codecs.scala new file mode 100644 index 0000000..751e79b --- /dev/null +++ b/domain/parameters/shared/codecs/src/Codecs.scala @@ -0,0 +1,10 @@ +package mdr.pdb +package parameters +package codecs + +import zio.json.* + +trait Codecs: + + given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen + given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/parameters/shared/json/src/Codecs.scala b/domain/parameters/shared/json/src/Codecs.scala deleted file mode 100644 index b849072..0000000 --- a/domain/parameters/shared/json/src/Codecs.scala +++ /dev/null @@ -1,10 +0,0 @@ -package mdr.pdb -package parameters -package json - -import zio.json.* - -trait Codecs: - - given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen - given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/proof/command/api/src/ProofCommandApi.scala b/domain/proof/command/api/src/ProofCommandApi.scala new file mode 100644 index 0000000..12f747e --- /dev/null +++ b/domain/proof/command/api/src/ProofCommandApi.scala @@ -0,0 +1,16 @@ +package mdr.pdb.proof.command +package api + +import endpoints.Endpoints +import entity.ProofCommandBus +import fiftyforms.tapir.CustomTapir +import fiftyforms.tapir.InternalServerError + +object ProofCommandApi extends CustomTapir: + + val submitCommand: ZServerEndpoint[ProofCommandBus, Any] = + Endpoints.submitCommand.zServerLogic(cmd => + ProofCommandBus + .submitCommand(cmd) + .mapError(InternalServerError.fromThrowable) + ) diff --git a/domain/proof/command/codecs/src/Codecs.scala b/domain/proof/command/codecs/src/Codecs.scala new file mode 100644 index 0000000..0e75f82 --- /dev/null +++ b/domain/proof/command/codecs/src/Codecs.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof.command +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends proof.codecs.JsonCodecs: + + given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen + given JsonCodec[CreateProof] = DeriveJsonCodec.gen + given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen + given JsonCodec[UpdateProof] = DeriveJsonCodec.gen + given JsonCodec[RevokeProof] = DeriveJsonCodec.gen + given JsonCodec[Command] = DeriveJsonCodec.gen + +trait TapirCodecs extends proof.codecs.TapirCodecs: + + given Schema[AuthorizeOption] = Schema.derived + given Schema[CreateProof] = Schema.derived + given Schema[AuthorizeProof] = Schema.derived + given Schema[UpdateProof] = Schema.derived + given Schema[RevokeProof] = Schema.derived + given Schema[Command] = Schema.derived diff --git a/domain/proof/command/endpoints/src/Endpoints.scala b/domain/proof/command/endpoints/src/Endpoints.scala new file mode 100644 index 0000000..dcd114e --- /dev/null +++ b/domain/proof/command/endpoints/src/Endpoints.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof +package command +package endpoints + +import fiftyforms.tapir.CustomTapir +import mdr.pdb.proof.command.codecs.Codecs +import fiftyforms.tapir.ServerError +import zio.json.JsonCodec +import zio.json.DeriveJsonCodec +import sttp.model.StatusCode + +object Endpoints + extends mdr.pdb.endpoints.Endpoints + with CustomTapir + with Codecs: + + val submitCommand: Endpoint[Unit, Command, ServerError, Unit, Any] = + endpoint + .in("command") + .in("proof") + .post + .in(jsonBody[Command]) + .out(statusCode(StatusCode.Accepted)) + .errorOut(jsonBody[ServerError]) diff --git a/domain/proof/command/entity/src/ProofBehavior.scala b/domain/proof/command/entity/src/ProofBehavior.scala new file mode 100644 index 0000000..f679981 --- /dev/null +++ b/domain/proof/command/entity/src/ProofBehavior.scala @@ -0,0 +1,120 @@ +package mdr.pdb +package proof +package command +package entity + +import akka.persistence.typed.scaladsl.Effect +import akka.persistence.typed.scaladsl.EventSourcedBehavior +import akka.persistence.typed.PersistenceId +import akka.actor.typed.Behavior +import akka.actor.typed.ActorRef +import akka.pattern.StatusReply +import akka.Done + +import fiftyforms.akka.* +import akka.actor.typed.ActorSystem +import akka.cluster.sharding.typed.scaladsl.EntityTypeKey +import akka.cluster.sharding.typed.scaladsl.EntityContext +import akka.cluster.sharding.typed.scaladsl.ClusterSharding +import akka.cluster.sharding.typed.scaladsl.Entity + +object ProofBehavior: + + type ReplyTo = ActorRef[StatusReply[Done]] + + case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) + + type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] + + type ProofReplyEffect = + akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] + + val EntityKey: EntityTypeKey[ProofCommand] = EntityTypeKey("Proof") + + def init(system: ActorSystem[_]): Unit = + val behaviorFactory: EntityContext[ProofCommand] => Behavior[ProofCommand] = + entityContext => ProofBehavior(entityContext.entityId) + ClusterSharding(system).init(Entity(EntityKey)(behaviorFactory)) + + def apply(persistenceId: String): Behavior[ProofCommand] = + import ProofEventHandler.* + EventSourcedBehavior + .withEnforcedReplies[ProofCommand, ProofEvent, State]( + persistenceId = PersistenceId.ofUniqueId(persistenceId), + emptyState = None, + commandHandler = handleProofCommand, + eventHandler = (state, event) => + state.handleEvent(event).orElse(unhandledEvent(event, state)) + ) + .withTagger(_ => Set(ProofEvent.Tag)) + + def handleProofCommand( + state: State, + command: ProofCommand + ): ProofReplyEffect = + handleCommand(state, command.command) match + case Some(events) => persist(events, command.meta, command.replyTo) + case _ => unhandled(command.command, command.replyTo) + + def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = + state match + case None => + cmd match + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + Authorized(note) + ) => + Some( + Seq( + ProofCreated(id, person, parameterId, criterionId, documents), + ProofAuthorized(id, note) + ) + ) + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + _ + ) => + Some( + Seq(ProofCreated(id, person, parameterId, criterionId, documents)) + ) + case _ => None + case Some(proof) if proof.isRevoked => None + case Some(proof) if proof.isAuthorized => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + case Some(proof) => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case UpdateProof(id, documents) => + Some(Seq(ProofUpdated(id, documents))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + + private def persist( + events: Seq[Event], + meta: WW, + replyTo: ReplyTo + ): ProofReplyEffect = + Effect + .persist(events.map(ProofEvent(_, meta))) + .thenReply(replyTo)(_ => StatusReply.Ack) + + private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = + Effect.unhandled.thenReply(replyTo)(s => + StatusReply.error(CommandNotAvailable(command, s)) + ) + + private def unhandledEvent(event: ProofEvent, state: State): Nothing = + throw UnhandledEvent(event, state) diff --git a/domain/proof/command/entity/src/ProofBehaviour.scala b/domain/proof/command/entity/src/ProofBehaviour.scala deleted file mode 100644 index e8c7e4e..0000000 --- a/domain/proof/command/entity/src/ProofBehaviour.scala +++ /dev/null @@ -1,108 +0,0 @@ -package mdr.pdb -package proof -package command -package entity - -import akka.persistence.typed.scaladsl.EventSourcedBehavior -import akka.persistence.typed.PersistenceId -import akka.actor.typed.Behavior -import akka.actor.typed.ActorRef -import akka.pattern.StatusReply -import akka.Done - -import fiftyforms.akka.* -import akka.persistence.typed.scaladsl.Effect - -object ProofBehaviour: - - type ReplyTo = ActorRef[StatusReply[Done]] - - case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) - - type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] - - type ProofReplyEffect = - akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] - - def apply(persistenceId: PersistenceId): Behavior[ProofCommand] = - import ProofEventHandler.* - EventSourcedBehavior - .withEnforcedReplies[ProofCommand, ProofEvent, State]( - persistenceId = persistenceId, - emptyState = None, - commandHandler = handleProofCommand, - eventHandler = (state, event) => - state.handleEvent(event).orElse(unhandledEvent(event, state)) - ) - .withTagger(_ => Set(ProofEvent.Tag)) - - def handleProofCommand( - state: State, - command: ProofCommand - ): ProofReplyEffect = - handleCommand(state, command.command) match - case Some(events) => persist(events, command.meta, command.replyTo) - case _ => unhandled(command.command, command.replyTo) - - def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = - state match - case None => - cmd match - case CreateProof( - id, - person, - parameterId, - criterionId, - documents, - Authorized(note) - ) => - Some( - Seq( - ProofCreated(id, person, parameterId, criterionId, documents), - ProofAuthorized(id, note) - ) - ) - case CreateProof( - id, - person, - parameterId, - criterionId, - documents, - _ - ) => - Some( - Seq(ProofCreated(id, person, parameterId, criterionId, documents)) - ) - case _ => None - case Some(proof) if proof.isRevoked => None - case Some(proof) if proof.isAuthorized => - cmd match - case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) - case RevokeProof(id, reason, since, documents) => - Some(Seq((ProofRevoked(id, reason, since, documents)))) - case _ => None - case Some(proof) => - cmd match - case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) - case UpdateProof(id, documents) => - Some(Seq(ProofUpdated(id, documents))) - case RevokeProof(id, reason, since, documents) => - Some(Seq((ProofRevoked(id, reason, since, documents)))) - case _ => None - - private def persist( - events: Seq[Event], - meta: WW, - replyTo: ReplyTo - ): ProofReplyEffect = - Effect - .persist(events.map(ProofEvent(_, meta))) - .thenReply(replyTo)(_ => StatusReply.Ack) - - private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = - Effect.unhandled.thenReply(replyTo)(s => - StatusReply.error(CommandNotAvailable(command, s)) - ) - - private def unhandledEvent(event: ProofEvent, state: State): Nothing = - throw UnhandledEvent(event, state) diff --git a/domain/proof/command/entity/src/ProofCommandBus.scala b/domain/proof/command/entity/src/ProofCommandBus.scala new file mode 100644 index 0000000..3167e02 --- /dev/null +++ b/domain/proof/command/entity/src/ProofCommandBus.scala @@ -0,0 +1,50 @@ +package mdr.pdb +package proof +package command +package entity + +import zio.* +import akka.actor.typed.ActorSystem +import akka.cluster.sharding.typed.scaladsl.EntityContext +import akka.actor.typed.Behavior +import akka.cluster.sharding.typed.scaladsl.ClusterSharding +import akka.util.Timeout +import java.time.Instant + +object ProofCommandBus: + def submitCommand(command: Command): RIO[ProofCommandBus, Unit] = + ZIO.serviceWithZIO(_.submitCommand(command)) + + val layer: RLayer[ActorSystem[_], ProofCommandBus] = + ZIO + .serviceWithZIO[ActorSystem[_]](system => + for + timeout <- Task.attempt( + Timeout.create( + system.settings.config.getDuration("proof-bus.timeout") + ) + ) + // TODO: init only once + _ <- Task.attempt(ProofBehavior.init(system)) + yield ProofCommandBus(system)(using timeout) + ) + .toLayer + +class ProofCommandBus(system: ActorSystem[_])(using timeout: Timeout): + private val sharding = ClusterSharding(system) + + def submitCommand(command: Command): Task[Unit] = + for + entityRef <- Task.attempt( + sharding.entityRefFor(ProofBehavior.EntityKey, command.id) + ) + reply <- ZIO.fromFuture(_ => + entityRef.askWithStatus( + ProofBehavior.ProofCommand( + command, + WhoWhen(OsobniCislo("0123"), Instant.now()), + _ + ) + ) + ) + yield () diff --git a/domain/proof/command/json/src/Codecs.scala b/domain/proof/command/json/src/Codecs.scala deleted file mode 100644 index 6867615..0000000 --- a/domain/proof/command/json/src/Codecs.scala +++ /dev/null @@ -1,13 +0,0 @@ -package mdr.pdb -package proof.command -package json - -import zio.json.* - -trait Codecs extends proof.json.Codecs: - - given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen - given JsonCodec[CreateProof] = DeriveJsonCodec.gen - given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen - given JsonCodec[UpdateProof] = DeriveJsonCodec.gen - given JsonCodec[RevokeProof] = DeriveJsonCodec.gen diff --git a/domain/proof/command/model/src/Command.scala b/domain/proof/command/model/src/Command.scala index 35a15af..1bff3cd 100644 --- a/domain/proof/command/model/src/Command.scala +++ b/domain/proof/command/model/src/Command.scala @@ -5,7 +5,8 @@ import java.time.LocalDate import java.time.Instant -sealed trait Command +sealed trait Command: + def id: Proof.Id sealed trait AuthorizeOption case object Unauthorized extends AuthorizeOption diff --git a/domain/proof/query/api/src/ProofQueryApi.scala b/domain/proof/query/api/src/ProofQueryApi.scala new file mode 100644 index 0000000..19acd07 --- /dev/null +++ b/domain/proof/query/api/src/ProofQueryApi.scala @@ -0,0 +1,16 @@ +package mdr.pdb.proof.query +package api + +import endpoints.Endpoints +import repo.ProofRepository +import fiftyforms.tapir.CustomTapir +import fiftyforms.tapir.InternalServerError + +object ProofQueryApi extends CustomTapir: + + val forUser: ZServerEndpoint[ProofRepository, Any] = + Endpoints.forUser.zServerLogic(osc => + ProofRepository + .matching(ProofRepository.OfPerson(osc)) + .mapError(InternalServerError.fromThrowable) + ) diff --git a/domain/proof/query/endpoints/src/Endpoints.scala b/domain/proof/query/endpoints/src/Endpoints.scala new file mode 100644 index 0000000..e3d9fb9 --- /dev/null +++ b/domain/proof/query/endpoints/src/Endpoints.scala @@ -0,0 +1,21 @@ +package mdr.pdb +package proof +package query.endpoints + +import fiftyforms.tapir.CustomTapir +import mdr.pdb.proof.codecs.Codecs +import fiftyforms.tapir.ServerError +import zio.json.JsonCodec +import zio.json.DeriveJsonCodec + +object Endpoints + extends mdr.pdb.endpoints.Endpoints + with CustomTapir + with Codecs: + + val forUser: Endpoint[Unit, OsobniCislo, ServerError, Seq[Proof], Any] = + endpoint + .in("proof") + .in(path[OsobniCislo]("osobniCislo")) + .out(jsonBody[Seq[Proof]]) + .errorOut(jsonBody[ServerError]) diff --git a/domain/proof/query/repo/src/MongoProofRepository.scala b/domain/proof/query/repo/src/MongoProofRepository.scala index fad055c..cf19e7a 100644 --- a/domain/proof/query/repo/src/MongoProofRepository.scala +++ b/domain/proof/query/repo/src/MongoProofRepository.scala @@ -9,6 +9,7 @@ import org.bson.json.JsonObject import fiftyforms.mongo.MongoJsonRepository +// TODO: extract common mongo repo config, just nest under mongo / case class MongoProofConfig(db: String, collection: String) object MongoProofConfig { @@ -47,7 +48,7 @@ collection: MongoCollection[JsonObject] ) extends ProofRepositoryWrite: import ProofRepository.* - import mdr.pdb.proof.json.Codecs.given + import mdr.pdb.proof.codecs.Codecs.given private val jsonRepo = MongoJsonRepository[Proof, String, Criteria]( collection, { diff --git a/domain/proof/shared/codecs/src/Codecs.scala b/domain/proof/shared/codecs/src/Codecs.scala new file mode 100644 index 0000000..068f37b --- /dev/null +++ b/domain/proof/shared/codecs/src/Codecs.scala @@ -0,0 +1,20 @@ +package mdr.pdb.proof +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends mdr.pdb.codecs.JsonCodecs: + given JsonCodec[Authorization] = DeriveJsonCodec.gen + given JsonCodec[RevocationReason] = DeriveJsonCodec.gen + given JsonCodec[Revocation] = DeriveJsonCodec.gen + given JsonCodec[Proof] = DeriveJsonCodec.gen + +trait TapirCodecs extends mdr.pdb.codecs.TapirCodecs: + given Schema[Authorization] = Schema.derived + given Schema[RevocationReason] = Schema.derived + given Schema[Revocation] = Schema.derived + given Schema[Proof] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/proof/shared/json/src/Codecs.scala b/domain/proof/shared/json/src/Codecs.scala deleted file mode 100644 index 251550e..0000000 --- a/domain/proof/shared/json/src/Codecs.scala +++ /dev/null @@ -1,13 +0,0 @@ -package mdr.pdb.proof -package json - -import zio.json.* - -trait Codecs extends mdr.pdb.json.Codecs: - - given JsonCodec[Authorization] = DeriveJsonCodec.gen - given JsonCodec[RevocationReason] = DeriveJsonCodec.gen - given JsonCodec[Revocation] = DeriveJsonCodec.gen - given JsonCodec[Proof] = DeriveJsonCodec.gen - -object Codecs extends Codecs diff --git a/domain/users/query/codecs/src/Codecs.scala b/domain/users/query/codecs/src/Codecs.scala new file mode 100644 index 0000000..d30f318 --- /dev/null +++ b/domain/users/query/codecs/src/Codecs.scala @@ -0,0 +1,21 @@ +package mdr.pdb +package users.query +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends mdr.pdb.codecs.JsonCodecs: + given JsonCodec[UserContract] = DeriveJsonCodec.gen + given JsonCodec[UserFunction] = DeriveJsonCodec.gen + given JsonCodec[UserInfo] = DeriveJsonCodec.gen + given JsonCodec[UserProfile] = DeriveJsonCodec.gen + +trait TapirCodecs extends mdr.pdb.codecs.TapirCodecs: + given Schema[UserContract] = Schema.derived + given Schema[UserFunction] = Schema.derived + given Schema[UserInfo] = Schema.derived + given Schema[UserProfile] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/users/query/endpoints/src/Endpoints.scala b/domain/users/query/endpoints/src/Endpoints.scala index de0b074..16bd210 100644 --- a/domain/users/query/endpoints/src/Endpoints.scala +++ b/domain/users/query/endpoints/src/Endpoints.scala @@ -3,7 +3,7 @@ import fiftyforms.tapir.CustomTapir import fiftyforms.tapir.ServerError -import mdr.pdb.users.query.json.Codecs +import mdr.pdb.users.query.codecs.Codecs object Endpoints extends mdr.pdb.endpoints.Endpoints diff --git a/domain/users/query/json/src/Codecs.scala b/domain/users/query/json/src/Codecs.scala deleted file mode 100644 index df7edd1..0000000 --- a/domain/users/query/json/src/Codecs.scala +++ /dev/null @@ -1,14 +0,0 @@ -package mdr.pdb -package users.query -package json - -import zio.json.* - -trait Codecs extends mdr.pdb.json.Codecs: - - given JsonCodec[UserContract] = DeriveJsonCodec.gen - given JsonCodec[UserFunction] = DeriveJsonCodec.gen - given JsonCodec[UserInfo] = DeriveJsonCodec.gen - given JsonCodec[UserProfile] = DeriveJsonCodec.gen - -object Codecs extends Codecs diff --git a/domain/users/query/repo/src/UsersRepository.scala b/domain/users/query/repo/src/UsersRepository.scala index 71b0674..6ae948e 100644 --- a/domain/users/query/repo/src/UsersRepository.scala +++ b/domain/users/query/repo/src/UsersRepository.scala @@ -18,7 +18,7 @@ ZLayer.fromZIO { val readUsers: Task[List[UserInfo]] = import zio.json.{*, given} - import mdr.pdb.users.query.json.Codecs.given + import mdr.pdb.users.query.codecs.Codecs.given for maybeUsers <- readJsonAs( getClass.getResource("/users.json") diff --git a/endpoints/src/main/scala/mdr/pdb/endpoints/Endpoints.scala b/endpoints/src/main/scala/mdr/pdb/endpoints/Endpoints.scala index 7cb36cb..20efe9d 100644 --- a/endpoints/src/main/scala/mdr/pdb/endpoints/Endpoints.scala +++ b/endpoints/src/main/scala/mdr/pdb/endpoints/Endpoints.scala @@ -2,10 +2,10 @@ package endpoints import fiftyforms.tapir.CustomTapir +import sttp.tapir.Codec.PlainCodec +import mdr.pdb.codecs.TapirCodecs -trait Endpoints extends CustomTapir: - given schemaForOsobniCislo: Schema[OsobniCislo] = Schema.string - +trait Endpoints extends CustomTapir with TapirCodecs: val alive: Endpoint[Unit, Unit, Unit, String, Any] = endpoint.in("alive").out(stringBody) diff --git a/fiftyforms/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala b/fiftyforms/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala index eff2357..58a4cfc 100644 --- a/fiftyforms/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala +++ b/fiftyforms/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala @@ -18,11 +18,11 @@ .to[MongoConfig] val fromEnv = ZConfig.fromSystemEnv(configDesc) -val client: RLayer[MongoConfig, MongoClient] = - (for - config <- ZIO.service[MongoConfig] - client <- Task.attempt(MongoClient(config.uri)) - yield client).toLayer +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZIO + .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) + .toLayer class MongoJsonRepository[Elem, Key, Criteria]( collection: MongoCollection[JsonObject], diff --git a/fiftyforms/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala b/fiftyforms/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala index 3882ae6..3603863 100644 --- a/fiftyforms/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala +++ b/fiftyforms/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala @@ -3,13 +3,12 @@ import sttp.tapir.Tapir import sttp.tapir.json.zio.TapirJsonZio import sttp.tapir.TapirAliases -import sttp.tapir.generic.SchemaDerivation trait CustomTapir extends Tapir with TapirJsonZio with TapirAliases - with SchemaDerivation - with CustomTapirPlatformSpecific + with CustomTapirPlatformSpecific: + given Schema[ServerError] = Schema.derived object CustomTapir extends CustomTapir diff --git a/json/src/main/scala/mdr/pdb/json/Codecs.scala b/json/src/main/scala/mdr/pdb/json/Codecs.scala deleted file mode 100644 index 31c411f..0000000 --- a/json/src/main/scala/mdr/pdb/json/Codecs.scala +++ /dev/null @@ -1,16 +0,0 @@ -package mdr.pdb -package json - -import zio.json.* - -trait Codecs: - - given JsonCodec[WhoWhen] = DeriveJsonCodec.gen - given JsonCodec[OsobniCislo] = - JsonCodec.string.transform(OsobniCislo.apply, _.toString) - given JsonFieldEncoder[OsobniCislo] = - JsonFieldEncoder.string.contramap(_.toString) - given JsonFieldDecoder[OsobniCislo] = - JsonFieldDecoder.string.map(OsobniCislo(_)) - -object Codecs extends Codecs diff --git a/project/DomainProjectsPlugin.scala b/project/DomainProjectsPlugin.scala index 03941e8..5e87014 100644 --- a/project/DomainProjectsPlugin.scala +++ b/project/DomainProjectsPlugin.scala @@ -21,14 +21,14 @@ case class EntityProject( model: CrossProject, - json: CrossProject, + codecs: CrossProject, query: QueryProjects, command: CommandProjects ) extends CompositeProject { def model(upd: CrossProject => CrossProject): EntityProject = copy(model = upd(model)) - def json(upd: CrossProject => CrossProject): EntityProject = - copy(json = upd(json)) + def codecs(upd: CrossProject => CrossProject): EntityProject = + copy(codecs = upd(codecs)) def query(upd: QueryProjects => QueryProjects): EntityProject = copy(query = upd(query)) def command(upd: CommandProjects => CommandProjects): EntityProject = @@ -43,14 +43,14 @@ ) def entity(upd: Project => Project): EntityProject = command(_.entity(upd)) override def componentProjects: Seq[Project] = - Seq(model, json).flatMap( + Seq(model, codecs).flatMap( _.componentProjects ) ++ query.componentProjects ++ command.componentProjects } case class CommonProjects( model: CrossProject, - json: CrossProject, + codecs: CrossProject, endpoints: CrossProject, client: Project, api: Project, @@ -58,8 +58,8 @@ ) extends CompositeProject { def model(upd: CrossProject => CrossProject): CommonProjects = copy(model = upd(model)) - def json(upd: CrossProject => CrossProject): CommonProjects = - copy(json = upd(json)) + def codecs(upd: CrossProject => CrossProject): CommonProjects = + copy(codecs = upd(codecs)) def endpoints(upd: CrossProject => CrossProject): CommonProjects = copy(endpoints = upd(endpoints)) def client(upd: Project => Project): CommonProjects = @@ -68,7 +68,7 @@ def components(upd: Project => Project): CommonProjects = copy(components = upd(components)) override def componentProjects: Seq[Project] = - Seq(model, json, endpoints).flatMap( + Seq(model, codecs, endpoints).flatMap( _.componentProjects ) ++ Seq(client, api, components) } @@ -81,9 +81,9 @@ val model = common.model def model(upd: CrossProject => CrossProject): QueryProjects = copy(common = common.model(upd)) - val json = common.json - def json(upd: CrossProject => CrossProject): QueryProjects = - copy(common = common.json(upd)) + val codecs = common.codecs + def codecs(upd: CrossProject => CrossProject): QueryProjects = + copy(common = common.codecs(upd)) val endpoints = common.endpoints def endpoints(upd: CrossProject => CrossProject): QueryProjects = copy(common = common.endpoints(upd)) @@ -108,9 +108,9 @@ val model = common.model def model(upd: CrossProject => CrossProject): CommandProjects = copy(common = common.model(upd)) - val json = common.json - def json(upd: CrossProject => CrossProject): CommandProjects = - copy(common = common.json(upd)) + val codecs = common.codecs + def codecs(upd: CrossProject => CrossProject): CommandProjects = + copy(common = common.codecs(upd)) val endpoints = common.endpoints def endpoints(upd: CrossProject => CrossProject): CommandProjects = copy(common = common.endpoints(upd)) @@ -185,20 +185,20 @@ def pb(kind: String) = new ProjectBuilder(b, base)(kind) val sh = pb("shared") val sharedModel = sh.cp("model").settings(IWDeps.zioPrelude) - val sharedJson = - sh.cp("json").settings(IWDeps.zioJson).dependsOn(sharedModel) + val sharedCodecs = + sh.cp("codecs").settings(IWDeps.zioJson).dependsOn(sharedModel) def commonProjects(kb: ProjectBuilder) = { import kb._ val model: CrossProject = cp("model").dependsOn(sharedModel) - val json: CrossProject = - cp("json").dependsOn(model, sharedJson) + val codecs: CrossProject = + cp("codecs").dependsOn(model, sharedCodecs) val endpoints: CrossProject = cp("endpoints") .settings( IWDeps.tapirCore, IWDeps.tapirZIOJson ) - .dependsOn(model, json) + .dependsOn(model, codecs) val client: Project = js("client").dependsOn(endpoints.projects(JSPlatform)) val api: Project = p("api") @@ -215,7 +215,7 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - CommonProjects(model, json, endpoints, client, api, components) + CommonProjects(model, codecs, endpoints, client, api, components) } def queryProjects = { @@ -226,7 +226,7 @@ .settings(IWDeps.useZIO(Test)) .dependsOn( common.model.projects(JVMPlatform), - common.json.projects(JVMPlatform) + common.codecs.projects(JVMPlatform) ) QueryProjects( common.api(_.dependsOn(repo)), @@ -239,18 +239,20 @@ val cb = pb("command") import cb._ val common = commonProjects(cb) - CommandProjects( - common, + val entity = p("entity").dependsOn( common.model.projects(JVMPlatform), - common.json.projects(JVMPlatform) + common.codecs.projects(JVMPlatform) ) + CommandProjects( + common.api(_.dependsOn(entity)), + entity ) } EntityProject( model = sharedModel, - json = sharedJson, + codecs = sharedCodecs, query = queryProjects, command = commandProjects ) diff --git a/server/src/main/scala/mdr/pdb/server/HttpApplication.scala b/server/src/main/scala/mdr/pdb/server/HttpApplication.scala index 1d63b52..4040e9d 100644 --- a/server/src/main/scala/mdr/pdb/server/HttpApplication.scala +++ b/server/src/main/scala/mdr/pdb/server/HttpApplication.scala @@ -33,7 +33,7 @@ import dsl.* val staticR = static.Routes(config) - val apiR = api.Routes() + val apiR = api.Routes def httpApp(appPath: String): HttpRoutes[AppTask] = Router( diff --git a/build.sbt b/build.sbt index e6f5e40..227e292 100644 --- a/build.sbt +++ b/build.sbt @@ -14,38 +14,38 @@ lazy val proof = entityProject("proof", file("domain/proof")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .repo(_.dependsOn(`mongo-support`)) .entity(_.dependsOn(`akka-persistence-support`)) .projection(_.dependsOn(`akka-persistence-support`)) - .endpoints(_.dependsOn(`tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val parameters = entityProject("parameters", file("domain/parameters")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) - .endpoints(_.dependsOn(`tapir-support`)) + .codecs(_.dependsOn(codecs, `tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val users = entityProject("users", file("domain/users")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val core = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("core")) -lazy val json = crossProject(JSPlatform, JVMPlatform) +lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) - .in(file("json")) + .in(file("codecs")) .settings(IWDeps.zioJson) - .dependsOn(core) + .dependsOn(core, `tapir-support`) lazy val endpoints = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("endpoints")) - .dependsOn(core, json, `tapir-support`) + .dependsOn(core, codecs, `tapir-support`) // TODO: move all from fiftyforms to iterative works lazy val ui = (project in file("fiftyforms/ui")) @@ -80,7 +80,11 @@ lazy val `akka-persistence-support` = project .in(file("fiftyforms/akka-persistence")) - .settings(IWDeps.akka.profiles.eventsourcedJdbcProjection) + .settings( + IWDeps.akka.libs.persistence, + libraryDependencies += "com.typesafe.akka" %% "akka-cluster-sharding-typed" % IWDeps.akka.V, + IWDeps.akka.profiles.eventsourcedJdbcProjection + ) lazy val app = (project in file("app")) .enablePlugins(ScalaJSPlugin, VitePlugin) @@ -166,6 +170,7 @@ proof.query.api, proof.command.api, proof.query.projection, + proof.command.entity, endpoints.jvm ) diff --git a/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala new file mode 100644 index 0000000..22ebfb8 --- /dev/null +++ b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala @@ -0,0 +1,26 @@ +package mdr.pdb +package codecs + +import zio.json.* +import fiftyforms.tapir.CustomTapir + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + given JsonCodec[WhoWhen] = DeriveJsonCodec.gen + given JsonCodec[OsobniCislo] = + JsonCodec.string.transform(OsobniCislo.apply, _.toString) + given JsonFieldEncoder[OsobniCislo] = + JsonFieldEncoder.string.contramap(_.toString) + given JsonFieldDecoder[OsobniCislo] = + JsonFieldDecoder.string.map(OsobniCislo(_)) + +trait TapirCodecs extends CustomTapir: + given Schema[OsobniCislo] = Schema.string + given Codec.PlainCodec[OsobniCislo] = + Codec.string.mapDecode(OsobniCislo.apply andThen DecodeResult.Value.apply)( + _.toString + ) + given Schema[WhoWhen] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/parameters/shared/codecs/src/Codecs.scala b/domain/parameters/shared/codecs/src/Codecs.scala new file mode 100644 index 0000000..751e79b --- /dev/null +++ b/domain/parameters/shared/codecs/src/Codecs.scala @@ -0,0 +1,10 @@ +package mdr.pdb +package parameters +package codecs + +import zio.json.* + +trait Codecs: + + given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen + given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/parameters/shared/json/src/Codecs.scala b/domain/parameters/shared/json/src/Codecs.scala deleted file mode 100644 index b849072..0000000 --- a/domain/parameters/shared/json/src/Codecs.scala +++ /dev/null @@ -1,10 +0,0 @@ -package mdr.pdb -package parameters -package json - -import zio.json.* - -trait Codecs: - - given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen - given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/proof/command/api/src/ProofCommandApi.scala b/domain/proof/command/api/src/ProofCommandApi.scala new file mode 100644 index 0000000..12f747e --- /dev/null +++ b/domain/proof/command/api/src/ProofCommandApi.scala @@ -0,0 +1,16 @@ +package mdr.pdb.proof.command +package api + +import endpoints.Endpoints +import entity.ProofCommandBus +import fiftyforms.tapir.CustomTapir +import fiftyforms.tapir.InternalServerError + +object ProofCommandApi extends CustomTapir: + + val submitCommand: ZServerEndpoint[ProofCommandBus, Any] = + Endpoints.submitCommand.zServerLogic(cmd => + ProofCommandBus + .submitCommand(cmd) + .mapError(InternalServerError.fromThrowable) + ) diff --git a/domain/proof/command/codecs/src/Codecs.scala b/domain/proof/command/codecs/src/Codecs.scala new file mode 100644 index 0000000..0e75f82 --- /dev/null +++ b/domain/proof/command/codecs/src/Codecs.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof.command +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends proof.codecs.JsonCodecs: + + given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen + given JsonCodec[CreateProof] = DeriveJsonCodec.gen + given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen + given JsonCodec[UpdateProof] = DeriveJsonCodec.gen + given JsonCodec[RevokeProof] = DeriveJsonCodec.gen + given JsonCodec[Command] = DeriveJsonCodec.gen + +trait TapirCodecs extends proof.codecs.TapirCodecs: + + given Schema[AuthorizeOption] = Schema.derived + given Schema[CreateProof] = Schema.derived + given Schema[AuthorizeProof] = Schema.derived + given Schema[UpdateProof] = Schema.derived + given Schema[RevokeProof] = Schema.derived + given Schema[Command] = Schema.derived diff --git a/domain/proof/command/endpoints/src/Endpoints.scala b/domain/proof/command/endpoints/src/Endpoints.scala new file mode 100644 index 0000000..dcd114e --- /dev/null +++ b/domain/proof/command/endpoints/src/Endpoints.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof +package command +package endpoints + +import fiftyforms.tapir.CustomTapir +import mdr.pdb.proof.command.codecs.Codecs +import fiftyforms.tapir.ServerError +import zio.json.JsonCodec +import zio.json.DeriveJsonCodec +import sttp.model.StatusCode + +object Endpoints + extends mdr.pdb.endpoints.Endpoints + with CustomTapir + with Codecs: + + val submitCommand: Endpoint[Unit, Command, ServerError, Unit, Any] = + endpoint + .in("command") + .in("proof") + .post + .in(jsonBody[Command]) + .out(statusCode(StatusCode.Accepted)) + .errorOut(jsonBody[ServerError]) diff --git a/domain/proof/command/entity/src/ProofBehavior.scala b/domain/proof/command/entity/src/ProofBehavior.scala new file mode 100644 index 0000000..f679981 --- /dev/null +++ b/domain/proof/command/entity/src/ProofBehavior.scala @@ -0,0 +1,120 @@ +package mdr.pdb +package proof +package command +package entity + +import akka.persistence.typed.scaladsl.Effect +import akka.persistence.typed.scaladsl.EventSourcedBehavior +import akka.persistence.typed.PersistenceId +import akka.actor.typed.Behavior +import akka.actor.typed.ActorRef +import akka.pattern.StatusReply +import akka.Done + +import fiftyforms.akka.* +import akka.actor.typed.ActorSystem +import akka.cluster.sharding.typed.scaladsl.EntityTypeKey +import akka.cluster.sharding.typed.scaladsl.EntityContext +import akka.cluster.sharding.typed.scaladsl.ClusterSharding +import akka.cluster.sharding.typed.scaladsl.Entity + +object ProofBehavior: + + type ReplyTo = ActorRef[StatusReply[Done]] + + case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) + + type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] + + type ProofReplyEffect = + akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] + + val EntityKey: EntityTypeKey[ProofCommand] = EntityTypeKey("Proof") + + def init(system: ActorSystem[_]): Unit = + val behaviorFactory: EntityContext[ProofCommand] => Behavior[ProofCommand] = + entityContext => ProofBehavior(entityContext.entityId) + ClusterSharding(system).init(Entity(EntityKey)(behaviorFactory)) + + def apply(persistenceId: String): Behavior[ProofCommand] = + import ProofEventHandler.* + EventSourcedBehavior + .withEnforcedReplies[ProofCommand, ProofEvent, State]( + persistenceId = PersistenceId.ofUniqueId(persistenceId), + emptyState = None, + commandHandler = handleProofCommand, + eventHandler = (state, event) => + state.handleEvent(event).orElse(unhandledEvent(event, state)) + ) + .withTagger(_ => Set(ProofEvent.Tag)) + + def handleProofCommand( + state: State, + command: ProofCommand + ): ProofReplyEffect = + handleCommand(state, command.command) match + case Some(events) => persist(events, command.meta, command.replyTo) + case _ => unhandled(command.command, command.replyTo) + + def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = + state match + case None => + cmd match + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + Authorized(note) + ) => + Some( + Seq( + ProofCreated(id, person, parameterId, criterionId, documents), + ProofAuthorized(id, note) + ) + ) + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + _ + ) => + Some( + Seq(ProofCreated(id, person, parameterId, criterionId, documents)) + ) + case _ => None + case Some(proof) if proof.isRevoked => None + case Some(proof) if proof.isAuthorized => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + case Some(proof) => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case UpdateProof(id, documents) => + Some(Seq(ProofUpdated(id, documents))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + + private def persist( + events: Seq[Event], + meta: WW, + replyTo: ReplyTo + ): ProofReplyEffect = + Effect + .persist(events.map(ProofEvent(_, meta))) + .thenReply(replyTo)(_ => StatusReply.Ack) + + private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = + Effect.unhandled.thenReply(replyTo)(s => + StatusReply.error(CommandNotAvailable(command, s)) + ) + + private def unhandledEvent(event: ProofEvent, state: State): Nothing = + throw UnhandledEvent(event, state) diff --git a/domain/proof/command/entity/src/ProofBehaviour.scala b/domain/proof/command/entity/src/ProofBehaviour.scala deleted file mode 100644 index e8c7e4e..0000000 --- a/domain/proof/command/entity/src/ProofBehaviour.scala +++ /dev/null @@ -1,108 +0,0 @@ -package mdr.pdb -package proof -package command -package entity - -import akka.persistence.typed.scaladsl.EventSourcedBehavior -import akka.persistence.typed.PersistenceId -import akka.actor.typed.Behavior -import akka.actor.typed.ActorRef -import akka.pattern.StatusReply -import akka.Done - -import fiftyforms.akka.* -import akka.persistence.typed.scaladsl.Effect - -object ProofBehaviour: - - type ReplyTo = ActorRef[StatusReply[Done]] - - case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) - - type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] - - type ProofReplyEffect = - akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] - - def apply(persistenceId: PersistenceId): Behavior[ProofCommand] = - import ProofEventHandler.* - EventSourcedBehavior - .withEnforcedReplies[ProofCommand, ProofEvent, State]( - persistenceId = persistenceId, - emptyState = None, - commandHandler = handleProofCommand, - eventHandler = (state, event) => - state.handleEvent(event).orElse(unhandledEvent(event, state)) - ) - .withTagger(_ => Set(ProofEvent.Tag)) - - def handleProofCommand( - state: State, - command: ProofCommand - ): ProofReplyEffect = - handleCommand(state, command.command) match - case Some(events) => persist(events, command.meta, command.replyTo) - case _ => unhandled(command.command, command.replyTo) - - def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = - state match - case None => - cmd match - case CreateProof( - id, - person, - parameterId, - criterionId, - documents, - Authorized(note) - ) => - Some( - Seq( - ProofCreated(id, person, parameterId, criterionId, documents), - ProofAuthorized(id, note) - ) - ) - case CreateProof( - id, - person, - parameterId, - criterionId, - documents, - _ - ) => - Some( - Seq(ProofCreated(id, person, parameterId, criterionId, documents)) - ) - case _ => None - case Some(proof) if proof.isRevoked => None - case Some(proof) if proof.isAuthorized => - cmd match - case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) - case RevokeProof(id, reason, since, documents) => - Some(Seq((ProofRevoked(id, reason, since, documents)))) - case _ => None - case Some(proof) => - cmd match - case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) - case UpdateProof(id, documents) => - Some(Seq(ProofUpdated(id, documents))) - case RevokeProof(id, reason, since, documents) => - Some(Seq((ProofRevoked(id, reason, since, documents)))) - case _ => None - - private def persist( - events: Seq[Event], - meta: WW, - replyTo: ReplyTo - ): ProofReplyEffect = - Effect - .persist(events.map(ProofEvent(_, meta))) - .thenReply(replyTo)(_ => StatusReply.Ack) - - private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = - Effect.unhandled.thenReply(replyTo)(s => - StatusReply.error(CommandNotAvailable(command, s)) - ) - - private def unhandledEvent(event: ProofEvent, state: State): Nothing = - throw UnhandledEvent(event, state) diff --git a/domain/proof/command/entity/src/ProofCommandBus.scala b/domain/proof/command/entity/src/ProofCommandBus.scala new file mode 100644 index 0000000..3167e02 --- /dev/null +++ b/domain/proof/command/entity/src/ProofCommandBus.scala @@ -0,0 +1,50 @@ +package mdr.pdb +package proof +package command +package entity + +import zio.* +import akka.actor.typed.ActorSystem +import akka.cluster.sharding.typed.scaladsl.EntityContext +import akka.actor.typed.Behavior +import akka.cluster.sharding.typed.scaladsl.ClusterSharding +import akka.util.Timeout +import java.time.Instant + +object ProofCommandBus: + def submitCommand(command: Command): RIO[ProofCommandBus, Unit] = + ZIO.serviceWithZIO(_.submitCommand(command)) + + val layer: RLayer[ActorSystem[_], ProofCommandBus] = + ZIO + .serviceWithZIO[ActorSystem[_]](system => + for + timeout <- Task.attempt( + Timeout.create( + system.settings.config.getDuration("proof-bus.timeout") + ) + ) + // TODO: init only once + _ <- Task.attempt(ProofBehavior.init(system)) + yield ProofCommandBus(system)(using timeout) + ) + .toLayer + +class ProofCommandBus(system: ActorSystem[_])(using timeout: Timeout): + private val sharding = ClusterSharding(system) + + def submitCommand(command: Command): Task[Unit] = + for + entityRef <- Task.attempt( + sharding.entityRefFor(ProofBehavior.EntityKey, command.id) + ) + reply <- ZIO.fromFuture(_ => + entityRef.askWithStatus( + ProofBehavior.ProofCommand( + command, + WhoWhen(OsobniCislo("0123"), Instant.now()), + _ + ) + ) + ) + yield () diff --git a/domain/proof/command/json/src/Codecs.scala b/domain/proof/command/json/src/Codecs.scala deleted file mode 100644 index 6867615..0000000 --- a/domain/proof/command/json/src/Codecs.scala +++ /dev/null @@ -1,13 +0,0 @@ -package mdr.pdb -package proof.command -package json - -import zio.json.* - -trait Codecs extends proof.json.Codecs: - - given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen - given JsonCodec[CreateProof] = DeriveJsonCodec.gen - given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen - given JsonCodec[UpdateProof] = DeriveJsonCodec.gen - given JsonCodec[RevokeProof] = DeriveJsonCodec.gen diff --git a/domain/proof/command/model/src/Command.scala b/domain/proof/command/model/src/Command.scala index 35a15af..1bff3cd 100644 --- a/domain/proof/command/model/src/Command.scala +++ b/domain/proof/command/model/src/Command.scala @@ -5,7 +5,8 @@ import java.time.LocalDate import java.time.Instant -sealed trait Command +sealed trait Command: + def id: Proof.Id sealed trait AuthorizeOption case object Unauthorized extends AuthorizeOption diff --git a/domain/proof/query/api/src/ProofQueryApi.scala b/domain/proof/query/api/src/ProofQueryApi.scala new file mode 100644 index 0000000..19acd07 --- /dev/null +++ b/domain/proof/query/api/src/ProofQueryApi.scala @@ -0,0 +1,16 @@ +package mdr.pdb.proof.query +package api + +import endpoints.Endpoints +import repo.ProofRepository +import fiftyforms.tapir.CustomTapir +import fiftyforms.tapir.InternalServerError + +object ProofQueryApi extends CustomTapir: + + val forUser: ZServerEndpoint[ProofRepository, Any] = + Endpoints.forUser.zServerLogic(osc => + ProofRepository + .matching(ProofRepository.OfPerson(osc)) + .mapError(InternalServerError.fromThrowable) + ) diff --git a/domain/proof/query/endpoints/src/Endpoints.scala b/domain/proof/query/endpoints/src/Endpoints.scala new file mode 100644 index 0000000..e3d9fb9 --- /dev/null +++ b/domain/proof/query/endpoints/src/Endpoints.scala @@ -0,0 +1,21 @@ +package mdr.pdb +package proof +package query.endpoints + +import fiftyforms.tapir.CustomTapir +import mdr.pdb.proof.codecs.Codecs +import fiftyforms.tapir.ServerError +import zio.json.JsonCodec +import zio.json.DeriveJsonCodec + +object Endpoints + extends mdr.pdb.endpoints.Endpoints + with CustomTapir + with Codecs: + + val forUser: Endpoint[Unit, OsobniCislo, ServerError, Seq[Proof], Any] = + endpoint + .in("proof") + .in(path[OsobniCislo]("osobniCislo")) + .out(jsonBody[Seq[Proof]]) + .errorOut(jsonBody[ServerError]) diff --git a/domain/proof/query/repo/src/MongoProofRepository.scala b/domain/proof/query/repo/src/MongoProofRepository.scala index fad055c..cf19e7a 100644 --- a/domain/proof/query/repo/src/MongoProofRepository.scala +++ b/domain/proof/query/repo/src/MongoProofRepository.scala @@ -9,6 +9,7 @@ import org.bson.json.JsonObject import fiftyforms.mongo.MongoJsonRepository +// TODO: extract common mongo repo config, just nest under mongo / case class MongoProofConfig(db: String, collection: String) object MongoProofConfig { @@ -47,7 +48,7 @@ collection: MongoCollection[JsonObject] ) extends ProofRepositoryWrite: import ProofRepository.* - import mdr.pdb.proof.json.Codecs.given + import mdr.pdb.proof.codecs.Codecs.given private val jsonRepo = MongoJsonRepository[Proof, String, Criteria]( collection, { diff --git a/domain/proof/shared/codecs/src/Codecs.scala b/domain/proof/shared/codecs/src/Codecs.scala new file mode 100644 index 0000000..068f37b --- /dev/null +++ b/domain/proof/shared/codecs/src/Codecs.scala @@ -0,0 +1,20 @@ +package mdr.pdb.proof +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends mdr.pdb.codecs.JsonCodecs: + given JsonCodec[Authorization] = DeriveJsonCodec.gen + given JsonCodec[RevocationReason] = DeriveJsonCodec.gen + given JsonCodec[Revocation] = DeriveJsonCodec.gen + given JsonCodec[Proof] = DeriveJsonCodec.gen + +trait TapirCodecs extends mdr.pdb.codecs.TapirCodecs: + given Schema[Authorization] = Schema.derived + given Schema[RevocationReason] = Schema.derived + given Schema[Revocation] = Schema.derived + given Schema[Proof] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/proof/shared/json/src/Codecs.scala b/domain/proof/shared/json/src/Codecs.scala deleted file mode 100644 index 251550e..0000000 --- a/domain/proof/shared/json/src/Codecs.scala +++ /dev/null @@ -1,13 +0,0 @@ -package mdr.pdb.proof -package json - -import zio.json.* - -trait Codecs extends mdr.pdb.json.Codecs: - - given JsonCodec[Authorization] = DeriveJsonCodec.gen - given JsonCodec[RevocationReason] = DeriveJsonCodec.gen - given JsonCodec[Revocation] = DeriveJsonCodec.gen - given JsonCodec[Proof] = DeriveJsonCodec.gen - -object Codecs extends Codecs diff --git a/domain/users/query/codecs/src/Codecs.scala b/domain/users/query/codecs/src/Codecs.scala new file mode 100644 index 0000000..d30f318 --- /dev/null +++ b/domain/users/query/codecs/src/Codecs.scala @@ -0,0 +1,21 @@ +package mdr.pdb +package users.query +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends mdr.pdb.codecs.JsonCodecs: + given JsonCodec[UserContract] = DeriveJsonCodec.gen + given JsonCodec[UserFunction] = DeriveJsonCodec.gen + given JsonCodec[UserInfo] = DeriveJsonCodec.gen + given JsonCodec[UserProfile] = DeriveJsonCodec.gen + +trait TapirCodecs extends mdr.pdb.codecs.TapirCodecs: + given Schema[UserContract] = Schema.derived + given Schema[UserFunction] = Schema.derived + given Schema[UserInfo] = Schema.derived + given Schema[UserProfile] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/users/query/endpoints/src/Endpoints.scala b/domain/users/query/endpoints/src/Endpoints.scala index de0b074..16bd210 100644 --- a/domain/users/query/endpoints/src/Endpoints.scala +++ b/domain/users/query/endpoints/src/Endpoints.scala @@ -3,7 +3,7 @@ import fiftyforms.tapir.CustomTapir import fiftyforms.tapir.ServerError -import mdr.pdb.users.query.json.Codecs +import mdr.pdb.users.query.codecs.Codecs object Endpoints extends mdr.pdb.endpoints.Endpoints diff --git a/domain/users/query/json/src/Codecs.scala b/domain/users/query/json/src/Codecs.scala deleted file mode 100644 index df7edd1..0000000 --- a/domain/users/query/json/src/Codecs.scala +++ /dev/null @@ -1,14 +0,0 @@ -package mdr.pdb -package users.query -package json - -import zio.json.* - -trait Codecs extends mdr.pdb.json.Codecs: - - given JsonCodec[UserContract] = DeriveJsonCodec.gen - given JsonCodec[UserFunction] = DeriveJsonCodec.gen - given JsonCodec[UserInfo] = DeriveJsonCodec.gen - given JsonCodec[UserProfile] = DeriveJsonCodec.gen - -object Codecs extends Codecs diff --git a/domain/users/query/repo/src/UsersRepository.scala b/domain/users/query/repo/src/UsersRepository.scala index 71b0674..6ae948e 100644 --- a/domain/users/query/repo/src/UsersRepository.scala +++ b/domain/users/query/repo/src/UsersRepository.scala @@ -18,7 +18,7 @@ ZLayer.fromZIO { val readUsers: Task[List[UserInfo]] = import zio.json.{*, given} - import mdr.pdb.users.query.json.Codecs.given + import mdr.pdb.users.query.codecs.Codecs.given for maybeUsers <- readJsonAs( getClass.getResource("/users.json") diff --git a/endpoints/src/main/scala/mdr/pdb/endpoints/Endpoints.scala b/endpoints/src/main/scala/mdr/pdb/endpoints/Endpoints.scala index 7cb36cb..20efe9d 100644 --- a/endpoints/src/main/scala/mdr/pdb/endpoints/Endpoints.scala +++ b/endpoints/src/main/scala/mdr/pdb/endpoints/Endpoints.scala @@ -2,10 +2,10 @@ package endpoints import fiftyforms.tapir.CustomTapir +import sttp.tapir.Codec.PlainCodec +import mdr.pdb.codecs.TapirCodecs -trait Endpoints extends CustomTapir: - given schemaForOsobniCislo: Schema[OsobniCislo] = Schema.string - +trait Endpoints extends CustomTapir with TapirCodecs: val alive: Endpoint[Unit, Unit, Unit, String, Any] = endpoint.in("alive").out(stringBody) diff --git a/fiftyforms/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala b/fiftyforms/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala index eff2357..58a4cfc 100644 --- a/fiftyforms/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala +++ b/fiftyforms/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala @@ -18,11 +18,11 @@ .to[MongoConfig] val fromEnv = ZConfig.fromSystemEnv(configDesc) -val client: RLayer[MongoConfig, MongoClient] = - (for - config <- ZIO.service[MongoConfig] - client <- Task.attempt(MongoClient(config.uri)) - yield client).toLayer +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZIO + .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) + .toLayer class MongoJsonRepository[Elem, Key, Criteria]( collection: MongoCollection[JsonObject], diff --git a/fiftyforms/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala b/fiftyforms/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala index 3882ae6..3603863 100644 --- a/fiftyforms/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala +++ b/fiftyforms/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala @@ -3,13 +3,12 @@ import sttp.tapir.Tapir import sttp.tapir.json.zio.TapirJsonZio import sttp.tapir.TapirAliases -import sttp.tapir.generic.SchemaDerivation trait CustomTapir extends Tapir with TapirJsonZio with TapirAliases - with SchemaDerivation - with CustomTapirPlatformSpecific + with CustomTapirPlatformSpecific: + given Schema[ServerError] = Schema.derived object CustomTapir extends CustomTapir diff --git a/json/src/main/scala/mdr/pdb/json/Codecs.scala b/json/src/main/scala/mdr/pdb/json/Codecs.scala deleted file mode 100644 index 31c411f..0000000 --- a/json/src/main/scala/mdr/pdb/json/Codecs.scala +++ /dev/null @@ -1,16 +0,0 @@ -package mdr.pdb -package json - -import zio.json.* - -trait Codecs: - - given JsonCodec[WhoWhen] = DeriveJsonCodec.gen - given JsonCodec[OsobniCislo] = - JsonCodec.string.transform(OsobniCislo.apply, _.toString) - given JsonFieldEncoder[OsobniCislo] = - JsonFieldEncoder.string.contramap(_.toString) - given JsonFieldDecoder[OsobniCislo] = - JsonFieldDecoder.string.map(OsobniCislo(_)) - -object Codecs extends Codecs diff --git a/project/DomainProjectsPlugin.scala b/project/DomainProjectsPlugin.scala index 03941e8..5e87014 100644 --- a/project/DomainProjectsPlugin.scala +++ b/project/DomainProjectsPlugin.scala @@ -21,14 +21,14 @@ case class EntityProject( model: CrossProject, - json: CrossProject, + codecs: CrossProject, query: QueryProjects, command: CommandProjects ) extends CompositeProject { def model(upd: CrossProject => CrossProject): EntityProject = copy(model = upd(model)) - def json(upd: CrossProject => CrossProject): EntityProject = - copy(json = upd(json)) + def codecs(upd: CrossProject => CrossProject): EntityProject = + copy(codecs = upd(codecs)) def query(upd: QueryProjects => QueryProjects): EntityProject = copy(query = upd(query)) def command(upd: CommandProjects => CommandProjects): EntityProject = @@ -43,14 +43,14 @@ ) def entity(upd: Project => Project): EntityProject = command(_.entity(upd)) override def componentProjects: Seq[Project] = - Seq(model, json).flatMap( + Seq(model, codecs).flatMap( _.componentProjects ) ++ query.componentProjects ++ command.componentProjects } case class CommonProjects( model: CrossProject, - json: CrossProject, + codecs: CrossProject, endpoints: CrossProject, client: Project, api: Project, @@ -58,8 +58,8 @@ ) extends CompositeProject { def model(upd: CrossProject => CrossProject): CommonProjects = copy(model = upd(model)) - def json(upd: CrossProject => CrossProject): CommonProjects = - copy(json = upd(json)) + def codecs(upd: CrossProject => CrossProject): CommonProjects = + copy(codecs = upd(codecs)) def endpoints(upd: CrossProject => CrossProject): CommonProjects = copy(endpoints = upd(endpoints)) def client(upd: Project => Project): CommonProjects = @@ -68,7 +68,7 @@ def components(upd: Project => Project): CommonProjects = copy(components = upd(components)) override def componentProjects: Seq[Project] = - Seq(model, json, endpoints).flatMap( + Seq(model, codecs, endpoints).flatMap( _.componentProjects ) ++ Seq(client, api, components) } @@ -81,9 +81,9 @@ val model = common.model def model(upd: CrossProject => CrossProject): QueryProjects = copy(common = common.model(upd)) - val json = common.json - def json(upd: CrossProject => CrossProject): QueryProjects = - copy(common = common.json(upd)) + val codecs = common.codecs + def codecs(upd: CrossProject => CrossProject): QueryProjects = + copy(common = common.codecs(upd)) val endpoints = common.endpoints def endpoints(upd: CrossProject => CrossProject): QueryProjects = copy(common = common.endpoints(upd)) @@ -108,9 +108,9 @@ val model = common.model def model(upd: CrossProject => CrossProject): CommandProjects = copy(common = common.model(upd)) - val json = common.json - def json(upd: CrossProject => CrossProject): CommandProjects = - copy(common = common.json(upd)) + val codecs = common.codecs + def codecs(upd: CrossProject => CrossProject): CommandProjects = + copy(common = common.codecs(upd)) val endpoints = common.endpoints def endpoints(upd: CrossProject => CrossProject): CommandProjects = copy(common = common.endpoints(upd)) @@ -185,20 +185,20 @@ def pb(kind: String) = new ProjectBuilder(b, base)(kind) val sh = pb("shared") val sharedModel = sh.cp("model").settings(IWDeps.zioPrelude) - val sharedJson = - sh.cp("json").settings(IWDeps.zioJson).dependsOn(sharedModel) + val sharedCodecs = + sh.cp("codecs").settings(IWDeps.zioJson).dependsOn(sharedModel) def commonProjects(kb: ProjectBuilder) = { import kb._ val model: CrossProject = cp("model").dependsOn(sharedModel) - val json: CrossProject = - cp("json").dependsOn(model, sharedJson) + val codecs: CrossProject = + cp("codecs").dependsOn(model, sharedCodecs) val endpoints: CrossProject = cp("endpoints") .settings( IWDeps.tapirCore, IWDeps.tapirZIOJson ) - .dependsOn(model, json) + .dependsOn(model, codecs) val client: Project = js("client").dependsOn(endpoints.projects(JSPlatform)) val api: Project = p("api") @@ -215,7 +215,7 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - CommonProjects(model, json, endpoints, client, api, components) + CommonProjects(model, codecs, endpoints, client, api, components) } def queryProjects = { @@ -226,7 +226,7 @@ .settings(IWDeps.useZIO(Test)) .dependsOn( common.model.projects(JVMPlatform), - common.json.projects(JVMPlatform) + common.codecs.projects(JVMPlatform) ) QueryProjects( common.api(_.dependsOn(repo)), @@ -239,18 +239,20 @@ val cb = pb("command") import cb._ val common = commonProjects(cb) - CommandProjects( - common, + val entity = p("entity").dependsOn( common.model.projects(JVMPlatform), - common.json.projects(JVMPlatform) + common.codecs.projects(JVMPlatform) ) + CommandProjects( + common.api(_.dependsOn(entity)), + entity ) } EntityProject( model = sharedModel, - json = sharedJson, + codecs = sharedCodecs, query = queryProjects, command = commandProjects ) diff --git a/server/src/main/scala/mdr/pdb/server/HttpApplication.scala b/server/src/main/scala/mdr/pdb/server/HttpApplication.scala index 1d63b52..4040e9d 100644 --- a/server/src/main/scala/mdr/pdb/server/HttpApplication.scala +++ b/server/src/main/scala/mdr/pdb/server/HttpApplication.scala @@ -33,7 +33,7 @@ import dsl.* val staticR = static.Routes(config) - val apiR = api.Routes() + val apiR = api.Routes def httpApp(appPath: String): HttpRoutes[AppTask] = Router( diff --git a/server/src/main/scala/mdr/pdb/server/Main.scala b/server/src/main/scala/mdr/pdb/server/Main.scala index 49418ae..f1af85d 100644 --- a/server/src/main/scala/mdr/pdb/server/Main.scala +++ b/server/src/main/scala/mdr/pdb/server/Main.scala @@ -1,10 +1,16 @@ package mdr.pdb.server import zio.* -import mdr.pdb.users.query.repo.* import zio.config.ReadError import zio.logging.* import zio.logging.backend.SLF4J +import mdr.pdb.users.query.repo.* +import mdr.pdb.proof.query.repo.* +import mdr.pdb.proof.command.entity.* +import fiftyforms.mongo.* +import org.mongodb.scala.MongoClient +import akka.actor.typed.ActorSystem +import akka.actor.typed.scaladsl.Behaviors object Main extends ZIOAppDefault: @@ -13,16 +19,29 @@ lazy val runtimeLayer: URLayer[AppEnv, Runtime[AppEnv]] = ZLayer.fromZIO(ZIO.runtime[AppEnv]) + lazy val mongoClientLayer: RLayer[ZEnv, MongoClient] = + MongoConfig.fromEnv >>> MongoClient.layer + + lazy val proofRepositoryLayer: RLayer[ZEnv, ProofRepository] = + (mongoClientLayer >+> MongoProofConfig.fromEnv) >>> MongoProofRepository.layer + lazy val securityLayer: ZLayer[AppEnv, ReadError[String], HttpSecurity] = security.Pac4jSecurityConfig.fromEnv ++ runtimeLayer >>> security.Pac4jHttpSecurity.layer lazy val httpAppLayer: ZLayer[AppEnv, ReadError[String], HttpApplication] = AppConfig.fromEnv ++ securityLayer >>> HttpApplicationLive.layer - lazy val appEnvLayer: TaskLayer[UsersRepository] = - MockUsersRepository.layer + lazy val actorSystemLayer: TaskLayer[ActorSystem[_]] = + Task.attempt(ActorSystem(Behaviors.empty, "MDR PDB")).toLayer - lazy val serverLayer: ZLayer[ZEnv, Throwable, HttpServer] = + lazy val proofCommandBusLayer: RLayer[ZEnv, ProofCommandBus] = + actorSystemLayer >>> ProofCommandBus.layer + + lazy val appEnvLayer + : RLayer[ZEnv, UsersRepository & ProofRepository & ProofCommandBus] = + MockUsersRepository.layer >+> proofRepositoryLayer >+> proofCommandBusLayer + + lazy val serverLayer: RLayer[ZEnv, HttpServer] = appEnvLayer >+> blaze.BlazeServerConfig.fromEnv >+> httpAppLayer >>> blaze.BlazeHttpServer.layer override def run = diff --git a/build.sbt b/build.sbt index e6f5e40..227e292 100644 --- a/build.sbt +++ b/build.sbt @@ -14,38 +14,38 @@ lazy val proof = entityProject("proof", file("domain/proof")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .repo(_.dependsOn(`mongo-support`)) .entity(_.dependsOn(`akka-persistence-support`)) .projection(_.dependsOn(`akka-persistence-support`)) - .endpoints(_.dependsOn(`tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val parameters = entityProject("parameters", file("domain/parameters")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) - .endpoints(_.dependsOn(`tapir-support`)) + .codecs(_.dependsOn(codecs, `tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val users = entityProject("users", file("domain/users")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val core = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("core")) -lazy val json = crossProject(JSPlatform, JVMPlatform) +lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) - .in(file("json")) + .in(file("codecs")) .settings(IWDeps.zioJson) - .dependsOn(core) + .dependsOn(core, `tapir-support`) lazy val endpoints = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("endpoints")) - .dependsOn(core, json, `tapir-support`) + .dependsOn(core, codecs, `tapir-support`) // TODO: move all from fiftyforms to iterative works lazy val ui = (project in file("fiftyforms/ui")) @@ -80,7 +80,11 @@ lazy val `akka-persistence-support` = project .in(file("fiftyforms/akka-persistence")) - .settings(IWDeps.akka.profiles.eventsourcedJdbcProjection) + .settings( + IWDeps.akka.libs.persistence, + libraryDependencies += "com.typesafe.akka" %% "akka-cluster-sharding-typed" % IWDeps.akka.V, + IWDeps.akka.profiles.eventsourcedJdbcProjection + ) lazy val app = (project in file("app")) .enablePlugins(ScalaJSPlugin, VitePlugin) @@ -166,6 +170,7 @@ proof.query.api, proof.command.api, proof.query.projection, + proof.command.entity, endpoints.jvm ) diff --git a/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala new file mode 100644 index 0000000..22ebfb8 --- /dev/null +++ b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala @@ -0,0 +1,26 @@ +package mdr.pdb +package codecs + +import zio.json.* +import fiftyforms.tapir.CustomTapir + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + given JsonCodec[WhoWhen] = DeriveJsonCodec.gen + given JsonCodec[OsobniCislo] = + JsonCodec.string.transform(OsobniCislo.apply, _.toString) + given JsonFieldEncoder[OsobniCislo] = + JsonFieldEncoder.string.contramap(_.toString) + given JsonFieldDecoder[OsobniCislo] = + JsonFieldDecoder.string.map(OsobniCislo(_)) + +trait TapirCodecs extends CustomTapir: + given Schema[OsobniCislo] = Schema.string + given Codec.PlainCodec[OsobniCislo] = + Codec.string.mapDecode(OsobniCislo.apply andThen DecodeResult.Value.apply)( + _.toString + ) + given Schema[WhoWhen] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/parameters/shared/codecs/src/Codecs.scala b/domain/parameters/shared/codecs/src/Codecs.scala new file mode 100644 index 0000000..751e79b --- /dev/null +++ b/domain/parameters/shared/codecs/src/Codecs.scala @@ -0,0 +1,10 @@ +package mdr.pdb +package parameters +package codecs + +import zio.json.* + +trait Codecs: + + given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen + given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/parameters/shared/json/src/Codecs.scala b/domain/parameters/shared/json/src/Codecs.scala deleted file mode 100644 index b849072..0000000 --- a/domain/parameters/shared/json/src/Codecs.scala +++ /dev/null @@ -1,10 +0,0 @@ -package mdr.pdb -package parameters -package json - -import zio.json.* - -trait Codecs: - - given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen - given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/proof/command/api/src/ProofCommandApi.scala b/domain/proof/command/api/src/ProofCommandApi.scala new file mode 100644 index 0000000..12f747e --- /dev/null +++ b/domain/proof/command/api/src/ProofCommandApi.scala @@ -0,0 +1,16 @@ +package mdr.pdb.proof.command +package api + +import endpoints.Endpoints +import entity.ProofCommandBus +import fiftyforms.tapir.CustomTapir +import fiftyforms.tapir.InternalServerError + +object ProofCommandApi extends CustomTapir: + + val submitCommand: ZServerEndpoint[ProofCommandBus, Any] = + Endpoints.submitCommand.zServerLogic(cmd => + ProofCommandBus + .submitCommand(cmd) + .mapError(InternalServerError.fromThrowable) + ) diff --git a/domain/proof/command/codecs/src/Codecs.scala b/domain/proof/command/codecs/src/Codecs.scala new file mode 100644 index 0000000..0e75f82 --- /dev/null +++ b/domain/proof/command/codecs/src/Codecs.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof.command +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends proof.codecs.JsonCodecs: + + given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen + given JsonCodec[CreateProof] = DeriveJsonCodec.gen + given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen + given JsonCodec[UpdateProof] = DeriveJsonCodec.gen + given JsonCodec[RevokeProof] = DeriveJsonCodec.gen + given JsonCodec[Command] = DeriveJsonCodec.gen + +trait TapirCodecs extends proof.codecs.TapirCodecs: + + given Schema[AuthorizeOption] = Schema.derived + given Schema[CreateProof] = Schema.derived + given Schema[AuthorizeProof] = Schema.derived + given Schema[UpdateProof] = Schema.derived + given Schema[RevokeProof] = Schema.derived + given Schema[Command] = Schema.derived diff --git a/domain/proof/command/endpoints/src/Endpoints.scala b/domain/proof/command/endpoints/src/Endpoints.scala new file mode 100644 index 0000000..dcd114e --- /dev/null +++ b/domain/proof/command/endpoints/src/Endpoints.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof +package command +package endpoints + +import fiftyforms.tapir.CustomTapir +import mdr.pdb.proof.command.codecs.Codecs +import fiftyforms.tapir.ServerError +import zio.json.JsonCodec +import zio.json.DeriveJsonCodec +import sttp.model.StatusCode + +object Endpoints + extends mdr.pdb.endpoints.Endpoints + with CustomTapir + with Codecs: + + val submitCommand: Endpoint[Unit, Command, ServerError, Unit, Any] = + endpoint + .in("command") + .in("proof") + .post + .in(jsonBody[Command]) + .out(statusCode(StatusCode.Accepted)) + .errorOut(jsonBody[ServerError]) diff --git a/domain/proof/command/entity/src/ProofBehavior.scala b/domain/proof/command/entity/src/ProofBehavior.scala new file mode 100644 index 0000000..f679981 --- /dev/null +++ b/domain/proof/command/entity/src/ProofBehavior.scala @@ -0,0 +1,120 @@ +package mdr.pdb +package proof +package command +package entity + +import akka.persistence.typed.scaladsl.Effect +import akka.persistence.typed.scaladsl.EventSourcedBehavior +import akka.persistence.typed.PersistenceId +import akka.actor.typed.Behavior +import akka.actor.typed.ActorRef +import akka.pattern.StatusReply +import akka.Done + +import fiftyforms.akka.* +import akka.actor.typed.ActorSystem +import akka.cluster.sharding.typed.scaladsl.EntityTypeKey +import akka.cluster.sharding.typed.scaladsl.EntityContext +import akka.cluster.sharding.typed.scaladsl.ClusterSharding +import akka.cluster.sharding.typed.scaladsl.Entity + +object ProofBehavior: + + type ReplyTo = ActorRef[StatusReply[Done]] + + case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) + + type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] + + type ProofReplyEffect = + akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] + + val EntityKey: EntityTypeKey[ProofCommand] = EntityTypeKey("Proof") + + def init(system: ActorSystem[_]): Unit = + val behaviorFactory: EntityContext[ProofCommand] => Behavior[ProofCommand] = + entityContext => ProofBehavior(entityContext.entityId) + ClusterSharding(system).init(Entity(EntityKey)(behaviorFactory)) + + def apply(persistenceId: String): Behavior[ProofCommand] = + import ProofEventHandler.* + EventSourcedBehavior + .withEnforcedReplies[ProofCommand, ProofEvent, State]( + persistenceId = PersistenceId.ofUniqueId(persistenceId), + emptyState = None, + commandHandler = handleProofCommand, + eventHandler = (state, event) => + state.handleEvent(event).orElse(unhandledEvent(event, state)) + ) + .withTagger(_ => Set(ProofEvent.Tag)) + + def handleProofCommand( + state: State, + command: ProofCommand + ): ProofReplyEffect = + handleCommand(state, command.command) match + case Some(events) => persist(events, command.meta, command.replyTo) + case _ => unhandled(command.command, command.replyTo) + + def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = + state match + case None => + cmd match + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + Authorized(note) + ) => + Some( + Seq( + ProofCreated(id, person, parameterId, criterionId, documents), + ProofAuthorized(id, note) + ) + ) + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + _ + ) => + Some( + Seq(ProofCreated(id, person, parameterId, criterionId, documents)) + ) + case _ => None + case Some(proof) if proof.isRevoked => None + case Some(proof) if proof.isAuthorized => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + case Some(proof) => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case UpdateProof(id, documents) => + Some(Seq(ProofUpdated(id, documents))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + + private def persist( + events: Seq[Event], + meta: WW, + replyTo: ReplyTo + ): ProofReplyEffect = + Effect + .persist(events.map(ProofEvent(_, meta))) + .thenReply(replyTo)(_ => StatusReply.Ack) + + private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = + Effect.unhandled.thenReply(replyTo)(s => + StatusReply.error(CommandNotAvailable(command, s)) + ) + + private def unhandledEvent(event: ProofEvent, state: State): Nothing = + throw UnhandledEvent(event, state) diff --git a/domain/proof/command/entity/src/ProofBehaviour.scala b/domain/proof/command/entity/src/ProofBehaviour.scala deleted file mode 100644 index e8c7e4e..0000000 --- a/domain/proof/command/entity/src/ProofBehaviour.scala +++ /dev/null @@ -1,108 +0,0 @@ -package mdr.pdb -package proof -package command -package entity - -import akka.persistence.typed.scaladsl.EventSourcedBehavior -import akka.persistence.typed.PersistenceId -import akka.actor.typed.Behavior -import akka.actor.typed.ActorRef -import akka.pattern.StatusReply -import akka.Done - -import fiftyforms.akka.* -import akka.persistence.typed.scaladsl.Effect - -object ProofBehaviour: - - type ReplyTo = ActorRef[StatusReply[Done]] - - case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) - - type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] - - type ProofReplyEffect = - akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] - - def apply(persistenceId: PersistenceId): Behavior[ProofCommand] = - import ProofEventHandler.* - EventSourcedBehavior - .withEnforcedReplies[ProofCommand, ProofEvent, State]( - persistenceId = persistenceId, - emptyState = None, - commandHandler = handleProofCommand, - eventHandler = (state, event) => - state.handleEvent(event).orElse(unhandledEvent(event, state)) - ) - .withTagger(_ => Set(ProofEvent.Tag)) - - def handleProofCommand( - state: State, - command: ProofCommand - ): ProofReplyEffect = - handleCommand(state, command.command) match - case Some(events) => persist(events, command.meta, command.replyTo) - case _ => unhandled(command.command, command.replyTo) - - def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = - state match - case None => - cmd match - case CreateProof( - id, - person, - parameterId, - criterionId, - documents, - Authorized(note) - ) => - Some( - Seq( - ProofCreated(id, person, parameterId, criterionId, documents), - ProofAuthorized(id, note) - ) - ) - case CreateProof( - id, - person, - parameterId, - criterionId, - documents, - _ - ) => - Some( - Seq(ProofCreated(id, person, parameterId, criterionId, documents)) - ) - case _ => None - case Some(proof) if proof.isRevoked => None - case Some(proof) if proof.isAuthorized => - cmd match - case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) - case RevokeProof(id, reason, since, documents) => - Some(Seq((ProofRevoked(id, reason, since, documents)))) - case _ => None - case Some(proof) => - cmd match - case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) - case UpdateProof(id, documents) => - Some(Seq(ProofUpdated(id, documents))) - case RevokeProof(id, reason, since, documents) => - Some(Seq((ProofRevoked(id, reason, since, documents)))) - case _ => None - - private def persist( - events: Seq[Event], - meta: WW, - replyTo: ReplyTo - ): ProofReplyEffect = - Effect - .persist(events.map(ProofEvent(_, meta))) - .thenReply(replyTo)(_ => StatusReply.Ack) - - private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = - Effect.unhandled.thenReply(replyTo)(s => - StatusReply.error(CommandNotAvailable(command, s)) - ) - - private def unhandledEvent(event: ProofEvent, state: State): Nothing = - throw UnhandledEvent(event, state) diff --git a/domain/proof/command/entity/src/ProofCommandBus.scala b/domain/proof/command/entity/src/ProofCommandBus.scala new file mode 100644 index 0000000..3167e02 --- /dev/null +++ b/domain/proof/command/entity/src/ProofCommandBus.scala @@ -0,0 +1,50 @@ +package mdr.pdb +package proof +package command +package entity + +import zio.* +import akka.actor.typed.ActorSystem +import akka.cluster.sharding.typed.scaladsl.EntityContext +import akka.actor.typed.Behavior +import akka.cluster.sharding.typed.scaladsl.ClusterSharding +import akka.util.Timeout +import java.time.Instant + +object ProofCommandBus: + def submitCommand(command: Command): RIO[ProofCommandBus, Unit] = + ZIO.serviceWithZIO(_.submitCommand(command)) + + val layer: RLayer[ActorSystem[_], ProofCommandBus] = + ZIO + .serviceWithZIO[ActorSystem[_]](system => + for + timeout <- Task.attempt( + Timeout.create( + system.settings.config.getDuration("proof-bus.timeout") + ) + ) + // TODO: init only once + _ <- Task.attempt(ProofBehavior.init(system)) + yield ProofCommandBus(system)(using timeout) + ) + .toLayer + +class ProofCommandBus(system: ActorSystem[_])(using timeout: Timeout): + private val sharding = ClusterSharding(system) + + def submitCommand(command: Command): Task[Unit] = + for + entityRef <- Task.attempt( + sharding.entityRefFor(ProofBehavior.EntityKey, command.id) + ) + reply <- ZIO.fromFuture(_ => + entityRef.askWithStatus( + ProofBehavior.ProofCommand( + command, + WhoWhen(OsobniCislo("0123"), Instant.now()), + _ + ) + ) + ) + yield () diff --git a/domain/proof/command/json/src/Codecs.scala b/domain/proof/command/json/src/Codecs.scala deleted file mode 100644 index 6867615..0000000 --- a/domain/proof/command/json/src/Codecs.scala +++ /dev/null @@ -1,13 +0,0 @@ -package mdr.pdb -package proof.command -package json - -import zio.json.* - -trait Codecs extends proof.json.Codecs: - - given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen - given JsonCodec[CreateProof] = DeriveJsonCodec.gen - given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen - given JsonCodec[UpdateProof] = DeriveJsonCodec.gen - given JsonCodec[RevokeProof] = DeriveJsonCodec.gen diff --git a/domain/proof/command/model/src/Command.scala b/domain/proof/command/model/src/Command.scala index 35a15af..1bff3cd 100644 --- a/domain/proof/command/model/src/Command.scala +++ b/domain/proof/command/model/src/Command.scala @@ -5,7 +5,8 @@ import java.time.LocalDate import java.time.Instant -sealed trait Command +sealed trait Command: + def id: Proof.Id sealed trait AuthorizeOption case object Unauthorized extends AuthorizeOption diff --git a/domain/proof/query/api/src/ProofQueryApi.scala b/domain/proof/query/api/src/ProofQueryApi.scala new file mode 100644 index 0000000..19acd07 --- /dev/null +++ b/domain/proof/query/api/src/ProofQueryApi.scala @@ -0,0 +1,16 @@ +package mdr.pdb.proof.query +package api + +import endpoints.Endpoints +import repo.ProofRepository +import fiftyforms.tapir.CustomTapir +import fiftyforms.tapir.InternalServerError + +object ProofQueryApi extends CustomTapir: + + val forUser: ZServerEndpoint[ProofRepository, Any] = + Endpoints.forUser.zServerLogic(osc => + ProofRepository + .matching(ProofRepository.OfPerson(osc)) + .mapError(InternalServerError.fromThrowable) + ) diff --git a/domain/proof/query/endpoints/src/Endpoints.scala b/domain/proof/query/endpoints/src/Endpoints.scala new file mode 100644 index 0000000..e3d9fb9 --- /dev/null +++ b/domain/proof/query/endpoints/src/Endpoints.scala @@ -0,0 +1,21 @@ +package mdr.pdb +package proof +package query.endpoints + +import fiftyforms.tapir.CustomTapir +import mdr.pdb.proof.codecs.Codecs +import fiftyforms.tapir.ServerError +import zio.json.JsonCodec +import zio.json.DeriveJsonCodec + +object Endpoints + extends mdr.pdb.endpoints.Endpoints + with CustomTapir + with Codecs: + + val forUser: Endpoint[Unit, OsobniCislo, ServerError, Seq[Proof], Any] = + endpoint + .in("proof") + .in(path[OsobniCislo]("osobniCislo")) + .out(jsonBody[Seq[Proof]]) + .errorOut(jsonBody[ServerError]) diff --git a/domain/proof/query/repo/src/MongoProofRepository.scala b/domain/proof/query/repo/src/MongoProofRepository.scala index fad055c..cf19e7a 100644 --- a/domain/proof/query/repo/src/MongoProofRepository.scala +++ b/domain/proof/query/repo/src/MongoProofRepository.scala @@ -9,6 +9,7 @@ import org.bson.json.JsonObject import fiftyforms.mongo.MongoJsonRepository +// TODO: extract common mongo repo config, just nest under mongo / case class MongoProofConfig(db: String, collection: String) object MongoProofConfig { @@ -47,7 +48,7 @@ collection: MongoCollection[JsonObject] ) extends ProofRepositoryWrite: import ProofRepository.* - import mdr.pdb.proof.json.Codecs.given + import mdr.pdb.proof.codecs.Codecs.given private val jsonRepo = MongoJsonRepository[Proof, String, Criteria]( collection, { diff --git a/domain/proof/shared/codecs/src/Codecs.scala b/domain/proof/shared/codecs/src/Codecs.scala new file mode 100644 index 0000000..068f37b --- /dev/null +++ b/domain/proof/shared/codecs/src/Codecs.scala @@ -0,0 +1,20 @@ +package mdr.pdb.proof +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends mdr.pdb.codecs.JsonCodecs: + given JsonCodec[Authorization] = DeriveJsonCodec.gen + given JsonCodec[RevocationReason] = DeriveJsonCodec.gen + given JsonCodec[Revocation] = DeriveJsonCodec.gen + given JsonCodec[Proof] = DeriveJsonCodec.gen + +trait TapirCodecs extends mdr.pdb.codecs.TapirCodecs: + given Schema[Authorization] = Schema.derived + given Schema[RevocationReason] = Schema.derived + given Schema[Revocation] = Schema.derived + given Schema[Proof] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/proof/shared/json/src/Codecs.scala b/domain/proof/shared/json/src/Codecs.scala deleted file mode 100644 index 251550e..0000000 --- a/domain/proof/shared/json/src/Codecs.scala +++ /dev/null @@ -1,13 +0,0 @@ -package mdr.pdb.proof -package json - -import zio.json.* - -trait Codecs extends mdr.pdb.json.Codecs: - - given JsonCodec[Authorization] = DeriveJsonCodec.gen - given JsonCodec[RevocationReason] = DeriveJsonCodec.gen - given JsonCodec[Revocation] = DeriveJsonCodec.gen - given JsonCodec[Proof] = DeriveJsonCodec.gen - -object Codecs extends Codecs diff --git a/domain/users/query/codecs/src/Codecs.scala b/domain/users/query/codecs/src/Codecs.scala new file mode 100644 index 0000000..d30f318 --- /dev/null +++ b/domain/users/query/codecs/src/Codecs.scala @@ -0,0 +1,21 @@ +package mdr.pdb +package users.query +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends mdr.pdb.codecs.JsonCodecs: + given JsonCodec[UserContract] = DeriveJsonCodec.gen + given JsonCodec[UserFunction] = DeriveJsonCodec.gen + given JsonCodec[UserInfo] = DeriveJsonCodec.gen + given JsonCodec[UserProfile] = DeriveJsonCodec.gen + +trait TapirCodecs extends mdr.pdb.codecs.TapirCodecs: + given Schema[UserContract] = Schema.derived + given Schema[UserFunction] = Schema.derived + given Schema[UserInfo] = Schema.derived + given Schema[UserProfile] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/users/query/endpoints/src/Endpoints.scala b/domain/users/query/endpoints/src/Endpoints.scala index de0b074..16bd210 100644 --- a/domain/users/query/endpoints/src/Endpoints.scala +++ b/domain/users/query/endpoints/src/Endpoints.scala @@ -3,7 +3,7 @@ import fiftyforms.tapir.CustomTapir import fiftyforms.tapir.ServerError -import mdr.pdb.users.query.json.Codecs +import mdr.pdb.users.query.codecs.Codecs object Endpoints extends mdr.pdb.endpoints.Endpoints diff --git a/domain/users/query/json/src/Codecs.scala b/domain/users/query/json/src/Codecs.scala deleted file mode 100644 index df7edd1..0000000 --- a/domain/users/query/json/src/Codecs.scala +++ /dev/null @@ -1,14 +0,0 @@ -package mdr.pdb -package users.query -package json - -import zio.json.* - -trait Codecs extends mdr.pdb.json.Codecs: - - given JsonCodec[UserContract] = DeriveJsonCodec.gen - given JsonCodec[UserFunction] = DeriveJsonCodec.gen - given JsonCodec[UserInfo] = DeriveJsonCodec.gen - given JsonCodec[UserProfile] = DeriveJsonCodec.gen - -object Codecs extends Codecs diff --git a/domain/users/query/repo/src/UsersRepository.scala b/domain/users/query/repo/src/UsersRepository.scala index 71b0674..6ae948e 100644 --- a/domain/users/query/repo/src/UsersRepository.scala +++ b/domain/users/query/repo/src/UsersRepository.scala @@ -18,7 +18,7 @@ ZLayer.fromZIO { val readUsers: Task[List[UserInfo]] = import zio.json.{*, given} - import mdr.pdb.users.query.json.Codecs.given + import mdr.pdb.users.query.codecs.Codecs.given for maybeUsers <- readJsonAs( getClass.getResource("/users.json") diff --git a/endpoints/src/main/scala/mdr/pdb/endpoints/Endpoints.scala b/endpoints/src/main/scala/mdr/pdb/endpoints/Endpoints.scala index 7cb36cb..20efe9d 100644 --- a/endpoints/src/main/scala/mdr/pdb/endpoints/Endpoints.scala +++ b/endpoints/src/main/scala/mdr/pdb/endpoints/Endpoints.scala @@ -2,10 +2,10 @@ package endpoints import fiftyforms.tapir.CustomTapir +import sttp.tapir.Codec.PlainCodec +import mdr.pdb.codecs.TapirCodecs -trait Endpoints extends CustomTapir: - given schemaForOsobniCislo: Schema[OsobniCislo] = Schema.string - +trait Endpoints extends CustomTapir with TapirCodecs: val alive: Endpoint[Unit, Unit, Unit, String, Any] = endpoint.in("alive").out(stringBody) diff --git a/fiftyforms/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala b/fiftyforms/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala index eff2357..58a4cfc 100644 --- a/fiftyforms/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala +++ b/fiftyforms/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala @@ -18,11 +18,11 @@ .to[MongoConfig] val fromEnv = ZConfig.fromSystemEnv(configDesc) -val client: RLayer[MongoConfig, MongoClient] = - (for - config <- ZIO.service[MongoConfig] - client <- Task.attempt(MongoClient(config.uri)) - yield client).toLayer +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZIO + .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) + .toLayer class MongoJsonRepository[Elem, Key, Criteria]( collection: MongoCollection[JsonObject], diff --git a/fiftyforms/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala b/fiftyforms/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala index 3882ae6..3603863 100644 --- a/fiftyforms/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala +++ b/fiftyforms/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala @@ -3,13 +3,12 @@ import sttp.tapir.Tapir import sttp.tapir.json.zio.TapirJsonZio import sttp.tapir.TapirAliases -import sttp.tapir.generic.SchemaDerivation trait CustomTapir extends Tapir with TapirJsonZio with TapirAliases - with SchemaDerivation - with CustomTapirPlatformSpecific + with CustomTapirPlatformSpecific: + given Schema[ServerError] = Schema.derived object CustomTapir extends CustomTapir diff --git a/json/src/main/scala/mdr/pdb/json/Codecs.scala b/json/src/main/scala/mdr/pdb/json/Codecs.scala deleted file mode 100644 index 31c411f..0000000 --- a/json/src/main/scala/mdr/pdb/json/Codecs.scala +++ /dev/null @@ -1,16 +0,0 @@ -package mdr.pdb -package json - -import zio.json.* - -trait Codecs: - - given JsonCodec[WhoWhen] = DeriveJsonCodec.gen - given JsonCodec[OsobniCislo] = - JsonCodec.string.transform(OsobniCislo.apply, _.toString) - given JsonFieldEncoder[OsobniCislo] = - JsonFieldEncoder.string.contramap(_.toString) - given JsonFieldDecoder[OsobniCislo] = - JsonFieldDecoder.string.map(OsobniCislo(_)) - -object Codecs extends Codecs diff --git a/project/DomainProjectsPlugin.scala b/project/DomainProjectsPlugin.scala index 03941e8..5e87014 100644 --- a/project/DomainProjectsPlugin.scala +++ b/project/DomainProjectsPlugin.scala @@ -21,14 +21,14 @@ case class EntityProject( model: CrossProject, - json: CrossProject, + codecs: CrossProject, query: QueryProjects, command: CommandProjects ) extends CompositeProject { def model(upd: CrossProject => CrossProject): EntityProject = copy(model = upd(model)) - def json(upd: CrossProject => CrossProject): EntityProject = - copy(json = upd(json)) + def codecs(upd: CrossProject => CrossProject): EntityProject = + copy(codecs = upd(codecs)) def query(upd: QueryProjects => QueryProjects): EntityProject = copy(query = upd(query)) def command(upd: CommandProjects => CommandProjects): EntityProject = @@ -43,14 +43,14 @@ ) def entity(upd: Project => Project): EntityProject = command(_.entity(upd)) override def componentProjects: Seq[Project] = - Seq(model, json).flatMap( + Seq(model, codecs).flatMap( _.componentProjects ) ++ query.componentProjects ++ command.componentProjects } case class CommonProjects( model: CrossProject, - json: CrossProject, + codecs: CrossProject, endpoints: CrossProject, client: Project, api: Project, @@ -58,8 +58,8 @@ ) extends CompositeProject { def model(upd: CrossProject => CrossProject): CommonProjects = copy(model = upd(model)) - def json(upd: CrossProject => CrossProject): CommonProjects = - copy(json = upd(json)) + def codecs(upd: CrossProject => CrossProject): CommonProjects = + copy(codecs = upd(codecs)) def endpoints(upd: CrossProject => CrossProject): CommonProjects = copy(endpoints = upd(endpoints)) def client(upd: Project => Project): CommonProjects = @@ -68,7 +68,7 @@ def components(upd: Project => Project): CommonProjects = copy(components = upd(components)) override def componentProjects: Seq[Project] = - Seq(model, json, endpoints).flatMap( + Seq(model, codecs, endpoints).flatMap( _.componentProjects ) ++ Seq(client, api, components) } @@ -81,9 +81,9 @@ val model = common.model def model(upd: CrossProject => CrossProject): QueryProjects = copy(common = common.model(upd)) - val json = common.json - def json(upd: CrossProject => CrossProject): QueryProjects = - copy(common = common.json(upd)) + val codecs = common.codecs + def codecs(upd: CrossProject => CrossProject): QueryProjects = + copy(common = common.codecs(upd)) val endpoints = common.endpoints def endpoints(upd: CrossProject => CrossProject): QueryProjects = copy(common = common.endpoints(upd)) @@ -108,9 +108,9 @@ val model = common.model def model(upd: CrossProject => CrossProject): CommandProjects = copy(common = common.model(upd)) - val json = common.json - def json(upd: CrossProject => CrossProject): CommandProjects = - copy(common = common.json(upd)) + val codecs = common.codecs + def codecs(upd: CrossProject => CrossProject): CommandProjects = + copy(common = common.codecs(upd)) val endpoints = common.endpoints def endpoints(upd: CrossProject => CrossProject): CommandProjects = copy(common = common.endpoints(upd)) @@ -185,20 +185,20 @@ def pb(kind: String) = new ProjectBuilder(b, base)(kind) val sh = pb("shared") val sharedModel = sh.cp("model").settings(IWDeps.zioPrelude) - val sharedJson = - sh.cp("json").settings(IWDeps.zioJson).dependsOn(sharedModel) + val sharedCodecs = + sh.cp("codecs").settings(IWDeps.zioJson).dependsOn(sharedModel) def commonProjects(kb: ProjectBuilder) = { import kb._ val model: CrossProject = cp("model").dependsOn(sharedModel) - val json: CrossProject = - cp("json").dependsOn(model, sharedJson) + val codecs: CrossProject = + cp("codecs").dependsOn(model, sharedCodecs) val endpoints: CrossProject = cp("endpoints") .settings( IWDeps.tapirCore, IWDeps.tapirZIOJson ) - .dependsOn(model, json) + .dependsOn(model, codecs) val client: Project = js("client").dependsOn(endpoints.projects(JSPlatform)) val api: Project = p("api") @@ -215,7 +215,7 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - CommonProjects(model, json, endpoints, client, api, components) + CommonProjects(model, codecs, endpoints, client, api, components) } def queryProjects = { @@ -226,7 +226,7 @@ .settings(IWDeps.useZIO(Test)) .dependsOn( common.model.projects(JVMPlatform), - common.json.projects(JVMPlatform) + common.codecs.projects(JVMPlatform) ) QueryProjects( common.api(_.dependsOn(repo)), @@ -239,18 +239,20 @@ val cb = pb("command") import cb._ val common = commonProjects(cb) - CommandProjects( - common, + val entity = p("entity").dependsOn( common.model.projects(JVMPlatform), - common.json.projects(JVMPlatform) + common.codecs.projects(JVMPlatform) ) + CommandProjects( + common.api(_.dependsOn(entity)), + entity ) } EntityProject( model = sharedModel, - json = sharedJson, + codecs = sharedCodecs, query = queryProjects, command = commandProjects ) diff --git a/server/src/main/scala/mdr/pdb/server/HttpApplication.scala b/server/src/main/scala/mdr/pdb/server/HttpApplication.scala index 1d63b52..4040e9d 100644 --- a/server/src/main/scala/mdr/pdb/server/HttpApplication.scala +++ b/server/src/main/scala/mdr/pdb/server/HttpApplication.scala @@ -33,7 +33,7 @@ import dsl.* val staticR = static.Routes(config) - val apiR = api.Routes() + val apiR = api.Routes def httpApp(appPath: String): HttpRoutes[AppTask] = Router( diff --git a/server/src/main/scala/mdr/pdb/server/Main.scala b/server/src/main/scala/mdr/pdb/server/Main.scala index 49418ae..f1af85d 100644 --- a/server/src/main/scala/mdr/pdb/server/Main.scala +++ b/server/src/main/scala/mdr/pdb/server/Main.scala @@ -1,10 +1,16 @@ package mdr.pdb.server import zio.* -import mdr.pdb.users.query.repo.* import zio.config.ReadError import zio.logging.* import zio.logging.backend.SLF4J +import mdr.pdb.users.query.repo.* +import mdr.pdb.proof.query.repo.* +import mdr.pdb.proof.command.entity.* +import fiftyforms.mongo.* +import org.mongodb.scala.MongoClient +import akka.actor.typed.ActorSystem +import akka.actor.typed.scaladsl.Behaviors object Main extends ZIOAppDefault: @@ -13,16 +19,29 @@ lazy val runtimeLayer: URLayer[AppEnv, Runtime[AppEnv]] = ZLayer.fromZIO(ZIO.runtime[AppEnv]) + lazy val mongoClientLayer: RLayer[ZEnv, MongoClient] = + MongoConfig.fromEnv >>> MongoClient.layer + + lazy val proofRepositoryLayer: RLayer[ZEnv, ProofRepository] = + (mongoClientLayer >+> MongoProofConfig.fromEnv) >>> MongoProofRepository.layer + lazy val securityLayer: ZLayer[AppEnv, ReadError[String], HttpSecurity] = security.Pac4jSecurityConfig.fromEnv ++ runtimeLayer >>> security.Pac4jHttpSecurity.layer lazy val httpAppLayer: ZLayer[AppEnv, ReadError[String], HttpApplication] = AppConfig.fromEnv ++ securityLayer >>> HttpApplicationLive.layer - lazy val appEnvLayer: TaskLayer[UsersRepository] = - MockUsersRepository.layer + lazy val actorSystemLayer: TaskLayer[ActorSystem[_]] = + Task.attempt(ActorSystem(Behaviors.empty, "MDR PDB")).toLayer - lazy val serverLayer: ZLayer[ZEnv, Throwable, HttpServer] = + lazy val proofCommandBusLayer: RLayer[ZEnv, ProofCommandBus] = + actorSystemLayer >>> ProofCommandBus.layer + + lazy val appEnvLayer + : RLayer[ZEnv, UsersRepository & ProofRepository & ProofCommandBus] = + MockUsersRepository.layer >+> proofRepositoryLayer >+> proofCommandBusLayer + + lazy val serverLayer: RLayer[ZEnv, HttpServer] = appEnvLayer >+> blaze.BlazeServerConfig.fromEnv >+> httpAppLayer >>> blaze.BlazeHttpServer.layer override def run = diff --git a/server/src/main/scala/mdr/pdb/server/api/Routes.scala b/server/src/main/scala/mdr/pdb/server/api/Routes.scala index 8d3622d..4550a2b 100644 --- a/server/src/main/scala/mdr/pdb/server/api/Routes.scala +++ b/server/src/main/scala/mdr/pdb/server/api/Routes.scala @@ -9,16 +9,22 @@ import scala.util.control.NonFatal import mdr.pdb.endpoints.Endpoints import mdr.pdb.users.query.api.UsersApi +import mdr.pdb.proof.query.api.ProofQueryApi +import mdr.pdb.proof.command.api.ProofCommandApi -class Routes(): - import CustomTapir.* +object Routes extends CustomTapir: import fiftyforms.tapir.InternalServerError val alive: ZServerEndpoint[AppEnv, Any] = Endpoints.alive.zServerLogic(_ => ZIO.succeed("ok")) val serverEndpoints: List[ZServerEndpoint[AppEnv, Any]] = - List(alive, UsersApi.list.widen[AppEnv]) + List( + alive, + UsersApi.list.widen[AppEnv], + ProofQueryApi.forUser.widen[AppEnv], + ProofCommandApi.submitCommand.widen[AppEnv] + ) val routes: AuthedRoutes[AppAuth, AppTask] = - Router("pdb/api" -> CustomTapir.from(serverEndpoints).toRoutes).local(_.req) + Router("pdb/api" -> from(serverEndpoints).toRoutes).local(_.req) diff --git a/build.sbt b/build.sbt index e6f5e40..227e292 100644 --- a/build.sbt +++ b/build.sbt @@ -14,38 +14,38 @@ lazy val proof = entityProject("proof", file("domain/proof")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .repo(_.dependsOn(`mongo-support`)) .entity(_.dependsOn(`akka-persistence-support`)) .projection(_.dependsOn(`akka-persistence-support`)) - .endpoints(_.dependsOn(`tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val parameters = entityProject("parameters", file("domain/parameters")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) - .endpoints(_.dependsOn(`tapir-support`)) + .codecs(_.dependsOn(codecs, `tapir-support`)) + .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val users = entityProject("users", file("domain/users")) .components(_.dependsOn(ui)) .model(_.dependsOn(core)) - .json(_.dependsOn(json)) + .codecs(_.dependsOn(codecs, `tapir-support`)) .endpoints(_.dependsOn(`tapir-support`, endpoints)) lazy val core = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("core")) -lazy val json = crossProject(JSPlatform, JVMPlatform) +lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) - .in(file("json")) + .in(file("codecs")) .settings(IWDeps.zioJson) - .dependsOn(core) + .dependsOn(core, `tapir-support`) lazy val endpoints = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("endpoints")) - .dependsOn(core, json, `tapir-support`) + .dependsOn(core, codecs, `tapir-support`) // TODO: move all from fiftyforms to iterative works lazy val ui = (project in file("fiftyforms/ui")) @@ -80,7 +80,11 @@ lazy val `akka-persistence-support` = project .in(file("fiftyforms/akka-persistence")) - .settings(IWDeps.akka.profiles.eventsourcedJdbcProjection) + .settings( + IWDeps.akka.libs.persistence, + libraryDependencies += "com.typesafe.akka" %% "akka-cluster-sharding-typed" % IWDeps.akka.V, + IWDeps.akka.profiles.eventsourcedJdbcProjection + ) lazy val app = (project in file("app")) .enablePlugins(ScalaJSPlugin, VitePlugin) @@ -166,6 +170,7 @@ proof.query.api, proof.command.api, proof.query.projection, + proof.command.entity, endpoints.jvm ) diff --git a/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala new file mode 100644 index 0000000..22ebfb8 --- /dev/null +++ b/codecs/src/main/scala/mdr/pdb/codecs/Codecs.scala @@ -0,0 +1,26 @@ +package mdr.pdb +package codecs + +import zio.json.* +import fiftyforms.tapir.CustomTapir + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + given JsonCodec[WhoWhen] = DeriveJsonCodec.gen + given JsonCodec[OsobniCislo] = + JsonCodec.string.transform(OsobniCislo.apply, _.toString) + given JsonFieldEncoder[OsobniCislo] = + JsonFieldEncoder.string.contramap(_.toString) + given JsonFieldDecoder[OsobniCislo] = + JsonFieldDecoder.string.map(OsobniCislo(_)) + +trait TapirCodecs extends CustomTapir: + given Schema[OsobniCislo] = Schema.string + given Codec.PlainCodec[OsobniCislo] = + Codec.string.mapDecode(OsobniCislo.apply andThen DecodeResult.Value.apply)( + _.toString + ) + given Schema[WhoWhen] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/parameters/shared/codecs/src/Codecs.scala b/domain/parameters/shared/codecs/src/Codecs.scala new file mode 100644 index 0000000..751e79b --- /dev/null +++ b/domain/parameters/shared/codecs/src/Codecs.scala @@ -0,0 +1,10 @@ +package mdr.pdb +package parameters +package codecs + +import zio.json.* + +trait Codecs: + + given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen + given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/parameters/shared/json/src/Codecs.scala b/domain/parameters/shared/json/src/Codecs.scala deleted file mode 100644 index b849072..0000000 --- a/domain/parameters/shared/json/src/Codecs.scala +++ /dev/null @@ -1,10 +0,0 @@ -package mdr.pdb -package parameters -package json - -import zio.json.* - -trait Codecs: - - given JsonCodec[ParameterCriterion] = DeriveJsonCodec.gen - given JsonCodec[Parameter] = DeriveJsonCodec.gen diff --git a/domain/proof/command/api/src/ProofCommandApi.scala b/domain/proof/command/api/src/ProofCommandApi.scala new file mode 100644 index 0000000..12f747e --- /dev/null +++ b/domain/proof/command/api/src/ProofCommandApi.scala @@ -0,0 +1,16 @@ +package mdr.pdb.proof.command +package api + +import endpoints.Endpoints +import entity.ProofCommandBus +import fiftyforms.tapir.CustomTapir +import fiftyforms.tapir.InternalServerError + +object ProofCommandApi extends CustomTapir: + + val submitCommand: ZServerEndpoint[ProofCommandBus, Any] = + Endpoints.submitCommand.zServerLogic(cmd => + ProofCommandBus + .submitCommand(cmd) + .mapError(InternalServerError.fromThrowable) + ) diff --git a/domain/proof/command/codecs/src/Codecs.scala b/domain/proof/command/codecs/src/Codecs.scala new file mode 100644 index 0000000..0e75f82 --- /dev/null +++ b/domain/proof/command/codecs/src/Codecs.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof.command +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends proof.codecs.JsonCodecs: + + given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen + given JsonCodec[CreateProof] = DeriveJsonCodec.gen + given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen + given JsonCodec[UpdateProof] = DeriveJsonCodec.gen + given JsonCodec[RevokeProof] = DeriveJsonCodec.gen + given JsonCodec[Command] = DeriveJsonCodec.gen + +trait TapirCodecs extends proof.codecs.TapirCodecs: + + given Schema[AuthorizeOption] = Schema.derived + given Schema[CreateProof] = Schema.derived + given Schema[AuthorizeProof] = Schema.derived + given Schema[UpdateProof] = Schema.derived + given Schema[RevokeProof] = Schema.derived + given Schema[Command] = Schema.derived diff --git a/domain/proof/command/endpoints/src/Endpoints.scala b/domain/proof/command/endpoints/src/Endpoints.scala new file mode 100644 index 0000000..dcd114e --- /dev/null +++ b/domain/proof/command/endpoints/src/Endpoints.scala @@ -0,0 +1,25 @@ +package mdr.pdb +package proof +package command +package endpoints + +import fiftyforms.tapir.CustomTapir +import mdr.pdb.proof.command.codecs.Codecs +import fiftyforms.tapir.ServerError +import zio.json.JsonCodec +import zio.json.DeriveJsonCodec +import sttp.model.StatusCode + +object Endpoints + extends mdr.pdb.endpoints.Endpoints + with CustomTapir + with Codecs: + + val submitCommand: Endpoint[Unit, Command, ServerError, Unit, Any] = + endpoint + .in("command") + .in("proof") + .post + .in(jsonBody[Command]) + .out(statusCode(StatusCode.Accepted)) + .errorOut(jsonBody[ServerError]) diff --git a/domain/proof/command/entity/src/ProofBehavior.scala b/domain/proof/command/entity/src/ProofBehavior.scala new file mode 100644 index 0000000..f679981 --- /dev/null +++ b/domain/proof/command/entity/src/ProofBehavior.scala @@ -0,0 +1,120 @@ +package mdr.pdb +package proof +package command +package entity + +import akka.persistence.typed.scaladsl.Effect +import akka.persistence.typed.scaladsl.EventSourcedBehavior +import akka.persistence.typed.PersistenceId +import akka.actor.typed.Behavior +import akka.actor.typed.ActorRef +import akka.pattern.StatusReply +import akka.Done + +import fiftyforms.akka.* +import akka.actor.typed.ActorSystem +import akka.cluster.sharding.typed.scaladsl.EntityTypeKey +import akka.cluster.sharding.typed.scaladsl.EntityContext +import akka.cluster.sharding.typed.scaladsl.ClusterSharding +import akka.cluster.sharding.typed.scaladsl.Entity + +object ProofBehavior: + + type ReplyTo = ActorRef[StatusReply[Done]] + + case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) + + type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] + + type ProofReplyEffect = + akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] + + val EntityKey: EntityTypeKey[ProofCommand] = EntityTypeKey("Proof") + + def init(system: ActorSystem[_]): Unit = + val behaviorFactory: EntityContext[ProofCommand] => Behavior[ProofCommand] = + entityContext => ProofBehavior(entityContext.entityId) + ClusterSharding(system).init(Entity(EntityKey)(behaviorFactory)) + + def apply(persistenceId: String): Behavior[ProofCommand] = + import ProofEventHandler.* + EventSourcedBehavior + .withEnforcedReplies[ProofCommand, ProofEvent, State]( + persistenceId = PersistenceId.ofUniqueId(persistenceId), + emptyState = None, + commandHandler = handleProofCommand, + eventHandler = (state, event) => + state.handleEvent(event).orElse(unhandledEvent(event, state)) + ) + .withTagger(_ => Set(ProofEvent.Tag)) + + def handleProofCommand( + state: State, + command: ProofCommand + ): ProofReplyEffect = + handleCommand(state, command.command) match + case Some(events) => persist(events, command.meta, command.replyTo) + case _ => unhandled(command.command, command.replyTo) + + def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = + state match + case None => + cmd match + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + Authorized(note) + ) => + Some( + Seq( + ProofCreated(id, person, parameterId, criterionId, documents), + ProofAuthorized(id, note) + ) + ) + case CreateProof( + id, + person, + parameterId, + criterionId, + documents, + _ + ) => + Some( + Seq(ProofCreated(id, person, parameterId, criterionId, documents)) + ) + case _ => None + case Some(proof) if proof.isRevoked => None + case Some(proof) if proof.isAuthorized => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + case Some(proof) => + cmd match + case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) + case UpdateProof(id, documents) => + Some(Seq(ProofUpdated(id, documents))) + case RevokeProof(id, reason, since, documents) => + Some(Seq((ProofRevoked(id, reason, since, documents)))) + case _ => None + + private def persist( + events: Seq[Event], + meta: WW, + replyTo: ReplyTo + ): ProofReplyEffect = + Effect + .persist(events.map(ProofEvent(_, meta))) + .thenReply(replyTo)(_ => StatusReply.Ack) + + private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = + Effect.unhandled.thenReply(replyTo)(s => + StatusReply.error(CommandNotAvailable(command, s)) + ) + + private def unhandledEvent(event: ProofEvent, state: State): Nothing = + throw UnhandledEvent(event, state) diff --git a/domain/proof/command/entity/src/ProofBehaviour.scala b/domain/proof/command/entity/src/ProofBehaviour.scala deleted file mode 100644 index e8c7e4e..0000000 --- a/domain/proof/command/entity/src/ProofBehaviour.scala +++ /dev/null @@ -1,108 +0,0 @@ -package mdr.pdb -package proof -package command -package entity - -import akka.persistence.typed.scaladsl.EventSourcedBehavior -import akka.persistence.typed.PersistenceId -import akka.actor.typed.Behavior -import akka.actor.typed.ActorRef -import akka.pattern.StatusReply -import akka.Done - -import fiftyforms.akka.* -import akka.persistence.typed.scaladsl.Effect - -object ProofBehaviour: - - type ReplyTo = ActorRef[StatusReply[Done]] - - case class ProofCommand(command: Command, meta: WW, replyTo: ReplyTo) - - type Effect = akka.persistence.typed.scaladsl.Effect[Event, State] - - type ProofReplyEffect = - akka.persistence.typed.scaladsl.ReplyEffect[ProofEvent, State] - - def apply(persistenceId: PersistenceId): Behavior[ProofCommand] = - import ProofEventHandler.* - EventSourcedBehavior - .withEnforcedReplies[ProofCommand, ProofEvent, State]( - persistenceId = persistenceId, - emptyState = None, - commandHandler = handleProofCommand, - eventHandler = (state, event) => - state.handleEvent(event).orElse(unhandledEvent(event, state)) - ) - .withTagger(_ => Set(ProofEvent.Tag)) - - def handleProofCommand( - state: State, - command: ProofCommand - ): ProofReplyEffect = - handleCommand(state, command.command) match - case Some(events) => persist(events, command.meta, command.replyTo) - case _ => unhandled(command.command, command.replyTo) - - def handleCommand(state: State, cmd: Command): Option[Seq[Event]] = - state match - case None => - cmd match - case CreateProof( - id, - person, - parameterId, - criterionId, - documents, - Authorized(note) - ) => - Some( - Seq( - ProofCreated(id, person, parameterId, criterionId, documents), - ProofAuthorized(id, note) - ) - ) - case CreateProof( - id, - person, - parameterId, - criterionId, - documents, - _ - ) => - Some( - Seq(ProofCreated(id, person, parameterId, criterionId, documents)) - ) - case _ => None - case Some(proof) if proof.isRevoked => None - case Some(proof) if proof.isAuthorized => - cmd match - case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) - case RevokeProof(id, reason, since, documents) => - Some(Seq((ProofRevoked(id, reason, since, documents)))) - case _ => None - case Some(proof) => - cmd match - case AuthorizeProof(id, note) => Some(Seq(ProofAuthorized(id, note))) - case UpdateProof(id, documents) => - Some(Seq(ProofUpdated(id, documents))) - case RevokeProof(id, reason, since, documents) => - Some(Seq((ProofRevoked(id, reason, since, documents)))) - case _ => None - - private def persist( - events: Seq[Event], - meta: WW, - replyTo: ReplyTo - ): ProofReplyEffect = - Effect - .persist(events.map(ProofEvent(_, meta))) - .thenReply(replyTo)(_ => StatusReply.Ack) - - private def unhandled(command: Command, replyTo: ReplyTo): ProofReplyEffect = - Effect.unhandled.thenReply(replyTo)(s => - StatusReply.error(CommandNotAvailable(command, s)) - ) - - private def unhandledEvent(event: ProofEvent, state: State): Nothing = - throw UnhandledEvent(event, state) diff --git a/domain/proof/command/entity/src/ProofCommandBus.scala b/domain/proof/command/entity/src/ProofCommandBus.scala new file mode 100644 index 0000000..3167e02 --- /dev/null +++ b/domain/proof/command/entity/src/ProofCommandBus.scala @@ -0,0 +1,50 @@ +package mdr.pdb +package proof +package command +package entity + +import zio.* +import akka.actor.typed.ActorSystem +import akka.cluster.sharding.typed.scaladsl.EntityContext +import akka.actor.typed.Behavior +import akka.cluster.sharding.typed.scaladsl.ClusterSharding +import akka.util.Timeout +import java.time.Instant + +object ProofCommandBus: + def submitCommand(command: Command): RIO[ProofCommandBus, Unit] = + ZIO.serviceWithZIO(_.submitCommand(command)) + + val layer: RLayer[ActorSystem[_], ProofCommandBus] = + ZIO + .serviceWithZIO[ActorSystem[_]](system => + for + timeout <- Task.attempt( + Timeout.create( + system.settings.config.getDuration("proof-bus.timeout") + ) + ) + // TODO: init only once + _ <- Task.attempt(ProofBehavior.init(system)) + yield ProofCommandBus(system)(using timeout) + ) + .toLayer + +class ProofCommandBus(system: ActorSystem[_])(using timeout: Timeout): + private val sharding = ClusterSharding(system) + + def submitCommand(command: Command): Task[Unit] = + for + entityRef <- Task.attempt( + sharding.entityRefFor(ProofBehavior.EntityKey, command.id) + ) + reply <- ZIO.fromFuture(_ => + entityRef.askWithStatus( + ProofBehavior.ProofCommand( + command, + WhoWhen(OsobniCislo("0123"), Instant.now()), + _ + ) + ) + ) + yield () diff --git a/domain/proof/command/json/src/Codecs.scala b/domain/proof/command/json/src/Codecs.scala deleted file mode 100644 index 6867615..0000000 --- a/domain/proof/command/json/src/Codecs.scala +++ /dev/null @@ -1,13 +0,0 @@ -package mdr.pdb -package proof.command -package json - -import zio.json.* - -trait Codecs extends proof.json.Codecs: - - given JsonCodec[AuthorizeOption] = DeriveJsonCodec.gen - given JsonCodec[CreateProof] = DeriveJsonCodec.gen - given JsonCodec[AuthorizeProof] = DeriveJsonCodec.gen - given JsonCodec[UpdateProof] = DeriveJsonCodec.gen - given JsonCodec[RevokeProof] = DeriveJsonCodec.gen diff --git a/domain/proof/command/model/src/Command.scala b/domain/proof/command/model/src/Command.scala index 35a15af..1bff3cd 100644 --- a/domain/proof/command/model/src/Command.scala +++ b/domain/proof/command/model/src/Command.scala @@ -5,7 +5,8 @@ import java.time.LocalDate import java.time.Instant -sealed trait Command +sealed trait Command: + def id: Proof.Id sealed trait AuthorizeOption case object Unauthorized extends AuthorizeOption diff --git a/domain/proof/query/api/src/ProofQueryApi.scala b/domain/proof/query/api/src/ProofQueryApi.scala new file mode 100644 index 0000000..19acd07 --- /dev/null +++ b/domain/proof/query/api/src/ProofQueryApi.scala @@ -0,0 +1,16 @@ +package mdr.pdb.proof.query +package api + +import endpoints.Endpoints +import repo.ProofRepository +import fiftyforms.tapir.CustomTapir +import fiftyforms.tapir.InternalServerError + +object ProofQueryApi extends CustomTapir: + + val forUser: ZServerEndpoint[ProofRepository, Any] = + Endpoints.forUser.zServerLogic(osc => + ProofRepository + .matching(ProofRepository.OfPerson(osc)) + .mapError(InternalServerError.fromThrowable) + ) diff --git a/domain/proof/query/endpoints/src/Endpoints.scala b/domain/proof/query/endpoints/src/Endpoints.scala new file mode 100644 index 0000000..e3d9fb9 --- /dev/null +++ b/domain/proof/query/endpoints/src/Endpoints.scala @@ -0,0 +1,21 @@ +package mdr.pdb +package proof +package query.endpoints + +import fiftyforms.tapir.CustomTapir +import mdr.pdb.proof.codecs.Codecs +import fiftyforms.tapir.ServerError +import zio.json.JsonCodec +import zio.json.DeriveJsonCodec + +object Endpoints + extends mdr.pdb.endpoints.Endpoints + with CustomTapir + with Codecs: + + val forUser: Endpoint[Unit, OsobniCislo, ServerError, Seq[Proof], Any] = + endpoint + .in("proof") + .in(path[OsobniCislo]("osobniCislo")) + .out(jsonBody[Seq[Proof]]) + .errorOut(jsonBody[ServerError]) diff --git a/domain/proof/query/repo/src/MongoProofRepository.scala b/domain/proof/query/repo/src/MongoProofRepository.scala index fad055c..cf19e7a 100644 --- a/domain/proof/query/repo/src/MongoProofRepository.scala +++ b/domain/proof/query/repo/src/MongoProofRepository.scala @@ -9,6 +9,7 @@ import org.bson.json.JsonObject import fiftyforms.mongo.MongoJsonRepository +// TODO: extract common mongo repo config, just nest under mongo / case class MongoProofConfig(db: String, collection: String) object MongoProofConfig { @@ -47,7 +48,7 @@ collection: MongoCollection[JsonObject] ) extends ProofRepositoryWrite: import ProofRepository.* - import mdr.pdb.proof.json.Codecs.given + import mdr.pdb.proof.codecs.Codecs.given private val jsonRepo = MongoJsonRepository[Proof, String, Criteria]( collection, { diff --git a/domain/proof/shared/codecs/src/Codecs.scala b/domain/proof/shared/codecs/src/Codecs.scala new file mode 100644 index 0000000..068f37b --- /dev/null +++ b/domain/proof/shared/codecs/src/Codecs.scala @@ -0,0 +1,20 @@ +package mdr.pdb.proof +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends mdr.pdb.codecs.JsonCodecs: + given JsonCodec[Authorization] = DeriveJsonCodec.gen + given JsonCodec[RevocationReason] = DeriveJsonCodec.gen + given JsonCodec[Revocation] = DeriveJsonCodec.gen + given JsonCodec[Proof] = DeriveJsonCodec.gen + +trait TapirCodecs extends mdr.pdb.codecs.TapirCodecs: + given Schema[Authorization] = Schema.derived + given Schema[RevocationReason] = Schema.derived + given Schema[Revocation] = Schema.derived + given Schema[Proof] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/proof/shared/json/src/Codecs.scala b/domain/proof/shared/json/src/Codecs.scala deleted file mode 100644 index 251550e..0000000 --- a/domain/proof/shared/json/src/Codecs.scala +++ /dev/null @@ -1,13 +0,0 @@ -package mdr.pdb.proof -package json - -import zio.json.* - -trait Codecs extends mdr.pdb.json.Codecs: - - given JsonCodec[Authorization] = DeriveJsonCodec.gen - given JsonCodec[RevocationReason] = DeriveJsonCodec.gen - given JsonCodec[Revocation] = DeriveJsonCodec.gen - given JsonCodec[Proof] = DeriveJsonCodec.gen - -object Codecs extends Codecs diff --git a/domain/users/query/codecs/src/Codecs.scala b/domain/users/query/codecs/src/Codecs.scala new file mode 100644 index 0000000..d30f318 --- /dev/null +++ b/domain/users/query/codecs/src/Codecs.scala @@ -0,0 +1,21 @@ +package mdr.pdb +package users.query +package codecs + +import zio.json.* + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs extends mdr.pdb.codecs.JsonCodecs: + given JsonCodec[UserContract] = DeriveJsonCodec.gen + given JsonCodec[UserFunction] = DeriveJsonCodec.gen + given JsonCodec[UserInfo] = DeriveJsonCodec.gen + given JsonCodec[UserProfile] = DeriveJsonCodec.gen + +trait TapirCodecs extends mdr.pdb.codecs.TapirCodecs: + given Schema[UserContract] = Schema.derived + given Schema[UserFunction] = Schema.derived + given Schema[UserInfo] = Schema.derived + given Schema[UserProfile] = Schema.derived + +object Codecs extends Codecs diff --git a/domain/users/query/endpoints/src/Endpoints.scala b/domain/users/query/endpoints/src/Endpoints.scala index de0b074..16bd210 100644 --- a/domain/users/query/endpoints/src/Endpoints.scala +++ b/domain/users/query/endpoints/src/Endpoints.scala @@ -3,7 +3,7 @@ import fiftyforms.tapir.CustomTapir import fiftyforms.tapir.ServerError -import mdr.pdb.users.query.json.Codecs +import mdr.pdb.users.query.codecs.Codecs object Endpoints extends mdr.pdb.endpoints.Endpoints diff --git a/domain/users/query/json/src/Codecs.scala b/domain/users/query/json/src/Codecs.scala deleted file mode 100644 index df7edd1..0000000 --- a/domain/users/query/json/src/Codecs.scala +++ /dev/null @@ -1,14 +0,0 @@ -package mdr.pdb -package users.query -package json - -import zio.json.* - -trait Codecs extends mdr.pdb.json.Codecs: - - given JsonCodec[UserContract] = DeriveJsonCodec.gen - given JsonCodec[UserFunction] = DeriveJsonCodec.gen - given JsonCodec[UserInfo] = DeriveJsonCodec.gen - given JsonCodec[UserProfile] = DeriveJsonCodec.gen - -object Codecs extends Codecs diff --git a/domain/users/query/repo/src/UsersRepository.scala b/domain/users/query/repo/src/UsersRepository.scala index 71b0674..6ae948e 100644 --- a/domain/users/query/repo/src/UsersRepository.scala +++ b/domain/users/query/repo/src/UsersRepository.scala @@ -18,7 +18,7 @@ ZLayer.fromZIO { val readUsers: Task[List[UserInfo]] = import zio.json.{*, given} - import mdr.pdb.users.query.json.Codecs.given + import mdr.pdb.users.query.codecs.Codecs.given for maybeUsers <- readJsonAs( getClass.getResource("/users.json") diff --git a/endpoints/src/main/scala/mdr/pdb/endpoints/Endpoints.scala b/endpoints/src/main/scala/mdr/pdb/endpoints/Endpoints.scala index 7cb36cb..20efe9d 100644 --- a/endpoints/src/main/scala/mdr/pdb/endpoints/Endpoints.scala +++ b/endpoints/src/main/scala/mdr/pdb/endpoints/Endpoints.scala @@ -2,10 +2,10 @@ package endpoints import fiftyforms.tapir.CustomTapir +import sttp.tapir.Codec.PlainCodec +import mdr.pdb.codecs.TapirCodecs -trait Endpoints extends CustomTapir: - given schemaForOsobniCislo: Schema[OsobniCislo] = Schema.string - +trait Endpoints extends CustomTapir with TapirCodecs: val alive: Endpoint[Unit, Unit, Unit, String, Any] = endpoint.in("alive").out(stringBody) diff --git a/fiftyforms/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala b/fiftyforms/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala index eff2357..58a4cfc 100644 --- a/fiftyforms/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala +++ b/fiftyforms/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala @@ -18,11 +18,11 @@ .to[MongoConfig] val fromEnv = ZConfig.fromSystemEnv(configDesc) -val client: RLayer[MongoConfig, MongoClient] = - (for - config <- ZIO.service[MongoConfig] - client <- Task.attempt(MongoClient(config.uri)) - yield client).toLayer +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZIO + .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) + .toLayer class MongoJsonRepository[Elem, Key, Criteria]( collection: MongoCollection[JsonObject], diff --git a/fiftyforms/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala b/fiftyforms/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala index 3882ae6..3603863 100644 --- a/fiftyforms/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala +++ b/fiftyforms/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala @@ -3,13 +3,12 @@ import sttp.tapir.Tapir import sttp.tapir.json.zio.TapirJsonZio import sttp.tapir.TapirAliases -import sttp.tapir.generic.SchemaDerivation trait CustomTapir extends Tapir with TapirJsonZio with TapirAliases - with SchemaDerivation - with CustomTapirPlatformSpecific + with CustomTapirPlatformSpecific: + given Schema[ServerError] = Schema.derived object CustomTapir extends CustomTapir diff --git a/json/src/main/scala/mdr/pdb/json/Codecs.scala b/json/src/main/scala/mdr/pdb/json/Codecs.scala deleted file mode 100644 index 31c411f..0000000 --- a/json/src/main/scala/mdr/pdb/json/Codecs.scala +++ /dev/null @@ -1,16 +0,0 @@ -package mdr.pdb -package json - -import zio.json.* - -trait Codecs: - - given JsonCodec[WhoWhen] = DeriveJsonCodec.gen - given JsonCodec[OsobniCislo] = - JsonCodec.string.transform(OsobniCislo.apply, _.toString) - given JsonFieldEncoder[OsobniCislo] = - JsonFieldEncoder.string.contramap(_.toString) - given JsonFieldDecoder[OsobniCislo] = - JsonFieldDecoder.string.map(OsobniCislo(_)) - -object Codecs extends Codecs diff --git a/project/DomainProjectsPlugin.scala b/project/DomainProjectsPlugin.scala index 03941e8..5e87014 100644 --- a/project/DomainProjectsPlugin.scala +++ b/project/DomainProjectsPlugin.scala @@ -21,14 +21,14 @@ case class EntityProject( model: CrossProject, - json: CrossProject, + codecs: CrossProject, query: QueryProjects, command: CommandProjects ) extends CompositeProject { def model(upd: CrossProject => CrossProject): EntityProject = copy(model = upd(model)) - def json(upd: CrossProject => CrossProject): EntityProject = - copy(json = upd(json)) + def codecs(upd: CrossProject => CrossProject): EntityProject = + copy(codecs = upd(codecs)) def query(upd: QueryProjects => QueryProjects): EntityProject = copy(query = upd(query)) def command(upd: CommandProjects => CommandProjects): EntityProject = @@ -43,14 +43,14 @@ ) def entity(upd: Project => Project): EntityProject = command(_.entity(upd)) override def componentProjects: Seq[Project] = - Seq(model, json).flatMap( + Seq(model, codecs).flatMap( _.componentProjects ) ++ query.componentProjects ++ command.componentProjects } case class CommonProjects( model: CrossProject, - json: CrossProject, + codecs: CrossProject, endpoints: CrossProject, client: Project, api: Project, @@ -58,8 +58,8 @@ ) extends CompositeProject { def model(upd: CrossProject => CrossProject): CommonProjects = copy(model = upd(model)) - def json(upd: CrossProject => CrossProject): CommonProjects = - copy(json = upd(json)) + def codecs(upd: CrossProject => CrossProject): CommonProjects = + copy(codecs = upd(codecs)) def endpoints(upd: CrossProject => CrossProject): CommonProjects = copy(endpoints = upd(endpoints)) def client(upd: Project => Project): CommonProjects = @@ -68,7 +68,7 @@ def components(upd: Project => Project): CommonProjects = copy(components = upd(components)) override def componentProjects: Seq[Project] = - Seq(model, json, endpoints).flatMap( + Seq(model, codecs, endpoints).flatMap( _.componentProjects ) ++ Seq(client, api, components) } @@ -81,9 +81,9 @@ val model = common.model def model(upd: CrossProject => CrossProject): QueryProjects = copy(common = common.model(upd)) - val json = common.json - def json(upd: CrossProject => CrossProject): QueryProjects = - copy(common = common.json(upd)) + val codecs = common.codecs + def codecs(upd: CrossProject => CrossProject): QueryProjects = + copy(common = common.codecs(upd)) val endpoints = common.endpoints def endpoints(upd: CrossProject => CrossProject): QueryProjects = copy(common = common.endpoints(upd)) @@ -108,9 +108,9 @@ val model = common.model def model(upd: CrossProject => CrossProject): CommandProjects = copy(common = common.model(upd)) - val json = common.json - def json(upd: CrossProject => CrossProject): CommandProjects = - copy(common = common.json(upd)) + val codecs = common.codecs + def codecs(upd: CrossProject => CrossProject): CommandProjects = + copy(common = common.codecs(upd)) val endpoints = common.endpoints def endpoints(upd: CrossProject => CrossProject): CommandProjects = copy(common = common.endpoints(upd)) @@ -185,20 +185,20 @@ def pb(kind: String) = new ProjectBuilder(b, base)(kind) val sh = pb("shared") val sharedModel = sh.cp("model").settings(IWDeps.zioPrelude) - val sharedJson = - sh.cp("json").settings(IWDeps.zioJson).dependsOn(sharedModel) + val sharedCodecs = + sh.cp("codecs").settings(IWDeps.zioJson).dependsOn(sharedModel) def commonProjects(kb: ProjectBuilder) = { import kb._ val model: CrossProject = cp("model").dependsOn(sharedModel) - val json: CrossProject = - cp("json").dependsOn(model, sharedJson) + val codecs: CrossProject = + cp("codecs").dependsOn(model, sharedCodecs) val endpoints: CrossProject = cp("endpoints") .settings( IWDeps.tapirCore, IWDeps.tapirZIOJson ) - .dependsOn(model, json) + .dependsOn(model, codecs) val client: Project = js("client").dependsOn(endpoints.projects(JSPlatform)) val api: Project = p("api") @@ -215,7 +215,7 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - CommonProjects(model, json, endpoints, client, api, components) + CommonProjects(model, codecs, endpoints, client, api, components) } def queryProjects = { @@ -226,7 +226,7 @@ .settings(IWDeps.useZIO(Test)) .dependsOn( common.model.projects(JVMPlatform), - common.json.projects(JVMPlatform) + common.codecs.projects(JVMPlatform) ) QueryProjects( common.api(_.dependsOn(repo)), @@ -239,18 +239,20 @@ val cb = pb("command") import cb._ val common = commonProjects(cb) - CommandProjects( - common, + val entity = p("entity").dependsOn( common.model.projects(JVMPlatform), - common.json.projects(JVMPlatform) + common.codecs.projects(JVMPlatform) ) + CommandProjects( + common.api(_.dependsOn(entity)), + entity ) } EntityProject( model = sharedModel, - json = sharedJson, + codecs = sharedCodecs, query = queryProjects, command = commandProjects ) diff --git a/server/src/main/scala/mdr/pdb/server/HttpApplication.scala b/server/src/main/scala/mdr/pdb/server/HttpApplication.scala index 1d63b52..4040e9d 100644 --- a/server/src/main/scala/mdr/pdb/server/HttpApplication.scala +++ b/server/src/main/scala/mdr/pdb/server/HttpApplication.scala @@ -33,7 +33,7 @@ import dsl.* val staticR = static.Routes(config) - val apiR = api.Routes() + val apiR = api.Routes def httpApp(appPath: String): HttpRoutes[AppTask] = Router( diff --git a/server/src/main/scala/mdr/pdb/server/Main.scala b/server/src/main/scala/mdr/pdb/server/Main.scala index 49418ae..f1af85d 100644 --- a/server/src/main/scala/mdr/pdb/server/Main.scala +++ b/server/src/main/scala/mdr/pdb/server/Main.scala @@ -1,10 +1,16 @@ package mdr.pdb.server import zio.* -import mdr.pdb.users.query.repo.* import zio.config.ReadError import zio.logging.* import zio.logging.backend.SLF4J +import mdr.pdb.users.query.repo.* +import mdr.pdb.proof.query.repo.* +import mdr.pdb.proof.command.entity.* +import fiftyforms.mongo.* +import org.mongodb.scala.MongoClient +import akka.actor.typed.ActorSystem +import akka.actor.typed.scaladsl.Behaviors object Main extends ZIOAppDefault: @@ -13,16 +19,29 @@ lazy val runtimeLayer: URLayer[AppEnv, Runtime[AppEnv]] = ZLayer.fromZIO(ZIO.runtime[AppEnv]) + lazy val mongoClientLayer: RLayer[ZEnv, MongoClient] = + MongoConfig.fromEnv >>> MongoClient.layer + + lazy val proofRepositoryLayer: RLayer[ZEnv, ProofRepository] = + (mongoClientLayer >+> MongoProofConfig.fromEnv) >>> MongoProofRepository.layer + lazy val securityLayer: ZLayer[AppEnv, ReadError[String], HttpSecurity] = security.Pac4jSecurityConfig.fromEnv ++ runtimeLayer >>> security.Pac4jHttpSecurity.layer lazy val httpAppLayer: ZLayer[AppEnv, ReadError[String], HttpApplication] = AppConfig.fromEnv ++ securityLayer >>> HttpApplicationLive.layer - lazy val appEnvLayer: TaskLayer[UsersRepository] = - MockUsersRepository.layer + lazy val actorSystemLayer: TaskLayer[ActorSystem[_]] = + Task.attempt(ActorSystem(Behaviors.empty, "MDR PDB")).toLayer - lazy val serverLayer: ZLayer[ZEnv, Throwable, HttpServer] = + lazy val proofCommandBusLayer: RLayer[ZEnv, ProofCommandBus] = + actorSystemLayer >>> ProofCommandBus.layer + + lazy val appEnvLayer + : RLayer[ZEnv, UsersRepository & ProofRepository & ProofCommandBus] = + MockUsersRepository.layer >+> proofRepositoryLayer >+> proofCommandBusLayer + + lazy val serverLayer: RLayer[ZEnv, HttpServer] = appEnvLayer >+> blaze.BlazeServerConfig.fromEnv >+> httpAppLayer >>> blaze.BlazeHttpServer.layer override def run = diff --git a/server/src/main/scala/mdr/pdb/server/api/Routes.scala b/server/src/main/scala/mdr/pdb/server/api/Routes.scala index 8d3622d..4550a2b 100644 --- a/server/src/main/scala/mdr/pdb/server/api/Routes.scala +++ b/server/src/main/scala/mdr/pdb/server/api/Routes.scala @@ -9,16 +9,22 @@ import scala.util.control.NonFatal import mdr.pdb.endpoints.Endpoints import mdr.pdb.users.query.api.UsersApi +import mdr.pdb.proof.query.api.ProofQueryApi +import mdr.pdb.proof.command.api.ProofCommandApi -class Routes(): - import CustomTapir.* +object Routes extends CustomTapir: import fiftyforms.tapir.InternalServerError val alive: ZServerEndpoint[AppEnv, Any] = Endpoints.alive.zServerLogic(_ => ZIO.succeed("ok")) val serverEndpoints: List[ZServerEndpoint[AppEnv, Any]] = - List(alive, UsersApi.list.widen[AppEnv]) + List( + alive, + UsersApi.list.widen[AppEnv], + ProofQueryApi.forUser.widen[AppEnv], + ProofCommandApi.submitCommand.widen[AppEnv] + ) val routes: AuthedRoutes[AppAuth, AppTask] = - Router("pdb/api" -> CustomTapir.from(serverEndpoints).toRoutes).local(_.req) + Router("pdb/api" -> from(serverEndpoints).toRoutes).local(_.req) diff --git a/server/src/main/scala/mdr/pdb/server/package.scala b/server/src/main/scala/mdr/pdb/server/package.scala index c077095..b7f1a3a 100644 --- a/server/src/main/scala/mdr/pdb/server/package.scala +++ b/server/src/main/scala/mdr/pdb/server/package.scala @@ -3,7 +3,9 @@ import zio.* import org.pac4j.core.profile.CommonProfile import mdr.pdb.users.query.repo.UsersRepository +import mdr.pdb.proof.query.repo.ProofRepository +import mdr.pdb.proof.command.entity.ProofCommandBus -type AppEnv = ZEnv & UsersRepository +type AppEnv = ZEnv & UsersRepository & ProofRepository & ProofCommandBus type AppTask = RIO[AppEnv, *] type AppAuth = List[CommonProfile]