diff --git a/api/src/lu/foyer/App.scala b/api/src/lu/foyer/App.scala index 3a0e69b..ad174d9 100644 --- a/api/src/lu/foyer/App.scala +++ b/api/src/lu/foyer/App.scala @@ -15,8 +15,25 @@ import java.net.URI import java.time.LocalDate import java.util.UUID -object App extends ZIOAppDefault: - val openAPI = OpenAPIGen.fromEndpoints(ClientController.endpoints) - val routes = ClientController.routes ++ SwaggerUI.routes("docs" / "openapi", openAPI) +object HttpServer: + def routes = + for + client <- ZIO.service[ClientController] + openAPI = OpenAPIGen.fromEndpoints(client.endpoints) + yield client.routes @@ Middleware.debug ++ SwaggerUI.routes("docs" / "openapi", openAPI) - override def run = Server.serve(routes).provide(Server.default) +object App extends ZIOAppDefault: + val app = + for + routes <- HttpServer.routes + server <- Server.serve(routes).provide(Server.default) + yield server + + override def run = app.provide( + CommandEngine.layer[ClientCommand, ClientEvent, ClientState], + ClientHandlers.layer, + ClientReducer.layer, + ClientEventRepositoryInMemory.layer, + ClientStateRepositoryInMemory.layer, + ClientController.layer + ) diff --git a/api/src/lu/foyer/CommandEngineController.scala b/api/src/lu/foyer/CommandEngineController.scala new file mode 100644 index 0000000..d99a205 --- /dev/null +++ b/api/src/lu/foyer/CommandEngineController.scala @@ -0,0 +1,104 @@ +package lu.foyer + +import zio.* +import zio.Console.* +import zio.http.* +import zio.http.codec.* +import zio.http.codec.PathCodec.path +import zio.http.endpoint.* +import zio.schema.* + +import java.net.URI +import java.time.LocalDate +import java.util.UUID +import lu.foyer.JsonApiResponse.One + +trait CommandEngineController[Command: Schema, Event: Schema, State: Schema]( + domain: String, + entityName: String) + extends JsonApiController: + + def commandEngine: CommandEngine[Command, Event, State] + + val onthology = s"$domain:$entityName" + + private val fetchMany = + Endpoint(Method.GET / entityName) + .query(HttpCodec.query[Page]) + .jsonApiMany[State] + + private val fetchOne = + Endpoint(Method.GET / entityName / uuid("entityId")) + .jsonApiOne[State] + + private val fetchEventsMany = + Endpoint(Method.GET / entityName / uuid("entityId") / "events") + .query(HttpCodec.query[Page]) + .jsonApiMany[Event] + + private val fetchEventsOne: Endpoint[(UUID, UUID), (UUID, UUID), JsonApiResponse.Error, One[ + Event + ], zio.http.endpoint.AuthType.None.type] = + Endpoint(Method.GET / entityName / uuid("entityId") / "events" / uuid("eventId")) + .jsonApiOne[Event] + + private def generateCommands = commandEngine.handlers.map(handler => + if handler.isCreate then generateCreateCommand(handler) + else generateUpdateCommand(handler) + ) + + private def generateCreateCommand(handler: CommandHandler[Command, Event, State]) = + given Schema[Command] = handler.commandSchema.asInstanceOf[Schema[Command]] + val endpoint = Endpoint(Method.POST / entityName / "commands" / handler.name) + .in[Command] + .jsonApiOne[Event] + val route = endpoint.implementJsonApiOneEvent(command => + for + entityId <- Random.nextUUID + (event, state) <- commandEngine + .handleCommand(command, handler.name, entityId) + yield Some(event) + ) + (endpoint, route) + + private def generateUpdateCommand(handler: CommandHandler[Command, Event, State]) = + given Schema[Command] = handler.commandSchema.asInstanceOf[Schema[Command]] + val endpoint = Endpoint(Method.PUT / entityName / uuid("entityId") / "commands" / handler.name) + .in[Command] + .jsonApiOne[Event] + val route = endpoint.implementJsonApiOneEvent((entityId, command) => + for (event, _) <- commandEngine + .handleCommand(command, handler.name, entityId) + yield Some(event) + ) + (endpoint, route) + + private val (commands, commandsRoutes) = generateCommands.unzip + + private val fetchManyRoute = + fetchMany.implementJsonApiManyEntity(commandEngine.stateRepo.fetchMany) + + private val fetchOneRoute = + fetchOne.implementJsonApiOneEntity(commandEngine.stateRepo.fetchOne) + + private val fetchEventsManyRoute = + fetchEventsMany.implementJsonApiManyEvent(commandEngine.eventRepo.fetchMany(_, _)) + + private val fetchEventsOneRoute = + fetchEventsOne.implementJsonApiOneEvent(commandEngine.eventRepo.fetchOne(_, _)) + + val endpoints = List( + fetchMany, + fetchOne, + fetchEventsMany, + fetchEventsOne + ) ++ commands + + val routes = Routes( + fetchManyRoute, + fetchOneRoute, + fetchEventsManyRoute, + fetchEventsOneRoute + ) ++ Routes.fromIterable(commandsRoutes) + +end CommandEngineController diff --git a/api/src/lu/foyer/JsonApiController.scala b/api/src/lu/foyer/JsonApiController.scala new file mode 100644 index 0000000..f82c873 --- /dev/null +++ b/api/src/lu/foyer/JsonApiController.scala @@ -0,0 +1,143 @@ +package lu.foyer + +import zio.* +import zio.schema.* +import zio.http.* +import zio.http.codec.* +import zio.http.codec.PathCodec.path +import zio.http.endpoint.* +import lu.foyer.JsonApiResponse.Many +import lu.foyer.JsonApiResponse.One +import java.util.UUID +import scala.annotation.targetName +import zio.schema.annotation.discriminatorName + +object JsonApiResponse: + + case class One[T]( + data: Entity[T], + links: Links) + derives Schema + + case class Many[T]( + data: List[Entity[T]], + links: Links, + meta: Meta) + derives Schema + + case class Links( + self: String, + first: Option[String] = None, + prev: Option[String] = None, + next: Option[String] = None, + last: Option[String] = None) + derives Schema + + case class Meta(totalRecords: Option[Long], totalPages: Option[Long]) derives Schema + + case class Entity[T](id: UUID, `type`: String, attributes: T) derives Schema + + @discriminatorName("errorType") + enum Error(title: String) derives Schema: + case NotFound(id: String) extends Error(s"Entity $id not found") + case InternalServerError(title: String) extends Error(title) + object Error: + given Schema[Error.NotFound] = DeriveSchema.gen + given Schema[Error.InternalServerError] = DeriveSchema.gen +end JsonApiResponse + +trait JsonApiController: + + def onthology: String + + extension [PathInput, Input, Auth <: AuthType]( + endpoint: Endpoint[PathInput, Input, ZNothing, ZNothing, AuthType.None] + ) + def jsonApiOne[Output: Schema] = + endpoint + .out[JsonApiResponse.One[Output]] + .outErrors[JsonApiResponse.Error]( + HttpCodec.error[JsonApiResponse.Error.NotFound](Status.NotFound), + HttpCodec.error[JsonApiResponse.Error.InternalServerError](Status.InternalServerError) + ) + def jsonApiMany[Output: Schema] = + endpoint + .out[JsonApiResponse.Many[Output]] + .outError[JsonApiResponse.Error](Status.InternalServerError) + + extension [PathInput, Input, Output, Auth <: AuthType]( + endpoint: Endpoint[PathInput, Input, JsonApiResponse.Error, One[Output], AuthType.None] + ) + def implementJsonApiOne[Env, A]( + f: Input => RIO[Env, Option[A]], + getId: A => UUID, + getEntity: A => Output + )(implicit trace: Trace + ): Route[Env, Nothing] = + endpoint.implement(input => + f(input) + .mapError(e => JsonApiResponse.Error.InternalServerError(e.getMessage)) + .someOrFail(JsonApiResponse.Error.NotFound(input.toString)) + .map(item => + JsonApiResponse.One( + JsonApiResponse.Entity( + id = getId(item), + `type` = onthology, + attributes = getEntity(item) + ), + JsonApiResponse.Links("https://api.example.org") // TODO + ) + ) + ) + + def implementJsonApiOneEntity[Env]( + f: Input => RIO[Env, Option[Entity[Output]]] + )(implicit trace: Trace + ): Route[Env, Nothing] = + implementJsonApiOne(f, _.entityId, _.data) + + def implementJsonApiOneEvent[Env]( + f: Input => RIO[Env, Option[Event[Output]]] + )(implicit trace: Trace + ): Route[Env, Nothing] = + implementJsonApiOne(f, _.eventId, _.data) + end extension + + extension [PathInput, Input, Output, Auth <: AuthType]( + endpoint: Endpoint[PathInput, Input, JsonApiResponse.Error, Many[Output], AuthType.None] + ) + def implementJsonApiMany[Env, A]( + f: Input => RIO[Env, Paged[A]], + getId: A => UUID, + getEntity: A => Output + )(implicit trace: Trace + ): Route[Env, Nothing] = + endpoint.implement( + f(_) + .map(paged => + JsonApiResponse.Many( + paged.items.map(item => + JsonApiResponse.Entity(id = getId(item), `type` = onthology, getEntity(item)) + ), + JsonApiResponse.Links("https://api.example.org"), + meta = JsonApiResponse.Meta(paged.totals, None) // TODO + ) + ) + .mapError(e => JsonApiResponse.Error.InternalServerError(e.getMessage)) + ) + + inline def implementJsonApiManyEntity[Env]( + f: Input => RIO[Env, Paged[Entity[Output]]] + )(implicit trace: Trace + ): Route[Env, Nothing] = + implementJsonApiMany(f, _.entityId, _.data) + + inline def implementJsonApiManyEvent[Env]( + f: Input => RIO[Env, Paged[Event[Output]]] + )(implicit trace: Trace + ): Route[Env, Nothing] = + implementJsonApiMany(f, _.eventId, _.data) + + end extension + +end JsonApiController diff --git a/api/src/lu/foyer/clients/ClientController.scala b/api/src/lu/foyer/clients/ClientController.scala index d2b69a9..e312aa0 100644 --- a/api/src/lu/foyer/clients/ClientController.scala +++ b/api/src/lu/foyer/clients/ClientController.scala @@ -13,162 +13,12 @@ import java.net.URI import java.time.LocalDate import java.util.UUID -object JsonApiResponse: - - case class One[T]( - data: Entity[T], - links: Links) - derives Schema - - case class Many[T]( - data: List[Entity[T]], - links: Links, - meta: Meta) - derives Schema - - case class Links( - self: String, - first: Option[String] = None, - prev: Option[String] = None, - next: Option[String] = None, - last: Option[String] = None) - derives Schema - - case class Meta(totalRecords: Option[Long], totalPages: Option[Long]) derives Schema - - case class Entity[T](id: String, `type`: String, attributes: T) derives Schema - - case class Error(message: String) derives Schema // TODO - -val pageParams = - (HttpCodec.query[Option[Int]]("page[number]") - & HttpCodec.query[Option[Int]]("page[size]") - & HttpCodec.query[Option[Boolean]]("page[totals]")) - .transform[Page]((number, size, totals) => - Page(number.getOrElse(0), size.getOrElse(50), totals.getOrElse(false)) - )(p => (Some(p._1), Some(p._2), Some(p._3))) - -trait CommandEngineController[Command: Schema, Event: Schema, State: Schema]( - entityName: String, - commands: List[Command], - commandEngine: CommandEngine[Command, Event, State]): - - private val fetchMany = - Endpoint(Method.GET / entityName) - .query(pageParams) - .out[JsonApiResponse.Many[State]] - .outError[JsonApiResponse.Error](Status.InternalServerError) - - private val fetchOne = - Endpoint(Method.GET / entityName / string("entityId")) - .out[JsonApiResponse.One[State]] - - private val fetchEventsMany = - Endpoint(Method.GET / entityName / string("entityId") / "events") - .query(pageParams) - .out[JsonApiResponse.Many[Event]] - - private val fetchEventsOne = - Endpoint(Method.GET / entityName / string("entityId") / "events" / string("eventId")) - .out[JsonApiResponse.One[Event]] - - private val commandsEndpoints = commands.map(command => - Endpoint(Method.POST / "clients" / "commands" / command.toString.toLowerCase) - .in[Command] - .out[JsonApiResponse.One[Event]] - ) - - private val fetchManyRoute = - fetchMany.implement(page => - commandEngine.stateRepo - .fetchMany(page) - .map(paged => - JsonApiResponse.Many( - paged.items.map(entity => - JsonApiResponse.Entity(entity.entityId.toString, "todo", entity.data) - ), - JsonApiResponse.Links("https://api.example.org"), - meta = JsonApiResponse.Meta(paged.totals, None) // TODO - ) - ).mapError(e => JsonApiResponse.Error(e.getMessage)) +class ClientController( + override val commandEngine: CommandEngine[ClientCommand, ClientEvent, ClientState]) + extends CommandEngineController[ClientCommand, ClientEvent, ClientState]( + "api:example:insurance", + "client" ) -end CommandEngineController object ClientController: - private val fetchMany = - Endpoint(Method.GET / "clients") - .query(pageParams) - .out[JsonApiResponse.Many[ClientState]] - - private val fetchOne = - Endpoint(Method.GET / "clients" / string("entityId")) - .out[JsonApiResponse.One[ClientState]] - - private val createCommand = - Endpoint(Method.POST / "clients" / "commands" / "create") - .in[ClientCommand.Create] - .out[JsonApiResponse.One[ClientEvent]] - - private val updateCommand = - Endpoint(Method.PUT / "clients" / string("entityId") / "commands" / "udpate") - .in[ClientCommand.Update] - .out[JsonApiResponse.One[ClientEvent]] - - private val disableCommand = - Endpoint(Method.PUT / "clients" / string("entityId") / "commands" / "disable") - .in[ClientCommand.Disable] - .out[JsonApiResponse.One[ClientEvent]] - - private val fetchEventsMany = - Endpoint(Method.GET / "clients" / string("entityId") / "events") - .query(pageParams) - .out[JsonApiResponse.Many[ClientEvent]] - - private val fetchEventsOne = - Endpoint(Method.GET / "clients" / string("entityId") / "events" / string("eventId")) - .out[JsonApiResponse.One[ClientEvent]] - - private val fetchManyRoute = - fetchMany.implement(page => - ZIO.succeed( - JsonApiResponse.Many[ClientState]( - List.empty, - JsonApiResponse.Links("https://api.example.org"), - meta = JsonApiResponse.Meta(None, None) - ) - ) - ) - - private val createCommandRoute = - createCommand.implement(command => - ZIO.succeed( - JsonApiResponse.One[ClientEvent]( - JsonApiResponse.Entity( - id = "todo", - `type` = "todo", - ClientEvent.Created( - lastName = command.lastName, - firstName = command.firstName, - birthDate = command.birthDate, - drivingLicenseDate = command.drivingLicenseDate, - phoneNumber = command.phoneNumber, - email = command.email, - address = command.address - ) - ), - JsonApiResponse.Links("https://api.example.org") - ) - ) - ) - - val endpoints = List( - fetchMany, - fetchOne, - createCommand, - updateCommand, - disableCommand, - fetchEventsMany, - fetchEventsOne - ) - val routes = Routes(fetchManyRoute) -end ClientController + val layer = ZLayer.fromFunction(ClientController.apply) diff --git a/api/src/lu/foyer/clients/ClientEventRepositoryInMemory.scala b/api/src/lu/foyer/clients/ClientEventRepositoryInMemory.scala new file mode 100644 index 0000000..c9e0018 --- /dev/null +++ b/api/src/lu/foyer/clients/ClientEventRepositoryInMemory.scala @@ -0,0 +1,23 @@ +package lu.foyer +package clients + +import java.util.UUID +import zio.* + +class ClientEventRepositoryInMemory(events: Ref[Map[UUID, Event[ClientEvent]]]) + extends EventRepository[ClientEvent] + with InMemoryRepository[Event[ClientEvent]](events): + def fetchOne(entityId: UUID, eventId: UUID): Task[Option[Event[ClientEvent]]] = + events.get.map(_.get(eventId)) + def fetchMany(entityId: UUID, page: Page): Task[Paged[Event[ClientEvent]]] = + events.get + .map(entities => + val items = entities.values + .filter(_.entityId == entityId) + .drop(page.number.getOrElse(0) * page.size.getOrElse(50)) + .take(page.size.getOrElse(50)) + Paged(items.toList, if page.totals.getOrElse(false) then Some(entities.size) else None) + ) + +object ClientEventRepositoryInMemory: + val layer = ZLayer.fromZIO(Ref.make(Map.empty).map(ClientEventRepositoryInMemory(_))) diff --git a/api/src/lu/foyer/clients/ClientStateRepositoryInMemory.scala b/api/src/lu/foyer/clients/ClientStateRepositoryInMemory.scala new file mode 100644 index 0000000..f6f5266 --- /dev/null +++ b/api/src/lu/foyer/clients/ClientStateRepositoryInMemory.scala @@ -0,0 +1,12 @@ +package lu.foyer +package clients + +import java.util.UUID +import zio.* + +class ClientStateRepositoryInMemory(clients: Ref[Map[UUID, Entity[ClientState]]]) + extends StateRepository[ClientState] + with InMemoryRepository[Entity[ClientState]](clients) + +object ClientStateRepositoryInMemory: + val layer = ZLayer.fromZIO(Ref.make(Map.empty).map(ClientStateRepositoryInMemory(_))) diff --git a/build.sc b/build.sc index f09ce35..a018887 100644 --- a/build.sc +++ b/build.sc @@ -1,12 +1,17 @@ // scalafmt: { runner.dialect = scala213 } package build import mill._, scalalib._ +import coursier.maven.MavenRepository + +val sonatypeSnapshots = Seq( + MavenRepository("https://oss.sonatype.org/content/repositories/snapshots") +) object Versions { val zio = "2.1.15" val zioJson = "0.7.33" val zioSchema = "1.6.3" - val zioHttp = "3.0.1" + val zioHttp = "3.0.1+97-29d12531-SNAPSHOT" val zioPrelude = "1.0.0-RC39" } @@ -18,6 +23,9 @@ trait CommonModule extends ScalaModule { ivy"dev.zio::zio-schema-derivation:${Versions.zioSchema}", ivy"dev.zio::zio-prelude:${Versions.zioPrelude}" ) + def repositoriesTask = Task.Anon { + super.repositoriesTask() ++ sonatypeSnapshots + } } object model extends CommonModule diff --git a/core/src/lu/foyer/EventSourcing.scala b/core/src/lu/foyer/EventSourcing.scala index af9aa0f..4212d79 100644 --- a/core/src/lu/foyer/EventSourcing.scala +++ b/core/src/lu/foyer/EventSourcing.scala @@ -2,25 +2,42 @@ package lu.foyer import zio.* import java.util.UUID +import zio.schema.Schema final case class Entity[T](entityId: UUID, data: T, version: Long) final case class Event[T](entityId: UUID, data: T, eventId: UUID) -trait StateRepository[State] extends Repository[Entity[State], UUID] -trait EventRepository[State] extends Repository[Event[State], UUID] +trait StateRepository[Data] extends Repository[Entity[Data], UUID] +trait EventRepository[Data] extends Repository[Event[Data], UUID]: + def fetchOne(entityId: UUID, eventId: UUID): Task[Option[Event[Data]]] + def fetchMany(entityId: UUID, page: Page): Task[Paged[Event[Data]]] trait Reducer[Event, State]: - def reduce(event: Event): Option[State] - def reduce(state: State, event: Event): Option[State] + def fromEmpty: PartialFunction[Event, State] + def fromState: PartialFunction[(State, Event), State] -trait CommandHandler[Command, Event, State]: + def reduce(event: Event): Option[State] = + fromEmpty.lift(event) + + def reduce(state: State, event: Event): Option[State] = + fromState.lift((state, event)) + +trait CommandHandler[+Command, +Event, +State]: def name: String + def isCreate: Boolean + inline def isUpdate: Boolean = !isCreate + def commandSchema: Schema[?] -trait CommandHandlerCreate[Command, Event, State] extends CommandHandler[Command, Event, State]: +trait CommandHandlerCreate[Command: Schema, Event] extends CommandHandler[Command, Event, Nothing]: def onCommand(entityId: UUID, command: Command): Task[Event] + val isCreate = true + val commandSchema = summon[Schema[Command]] -trait CommandHandlerUpdate[Command, Event, State] extends CommandHandler[Command, Event, State]: +trait CommandHandlerUpdate[Command: Schema, Event, State] + extends CommandHandler[Command, Event, State]: def onCommand(entityId: UUID, state: State, command: Command): Task[Event] + val isCreate = false + val commandSchema = summon[Schema[Command]] class CommandEngine[Command, Event, State]( val handlers: List[CommandHandler[Command, Event, State]], @@ -28,7 +45,8 @@ class CommandEngine[Command, Event, State]( val eventRepo: EventRepository[Event], val stateRepo: StateRepository[State]): - def handleCommand(command: Command, name: String, entityId: UUID): Task[(Event, State)] = + def handleCommand(command: Command, name: String, entityId: UUID) + : Task[(lu.foyer.Event[Event], lu.foyer.Entity[State])] = for handler <- ZIO .succeed(handlers.find(_.name == name)) @@ -39,13 +57,12 @@ class CommandEngine[Command, Event, State]( ZIO .succeed(newStateOption) .someOrFail(new IllegalArgumentException("Reducer cannot resolve state transition")) - newEntity <- - Random.nextUUID.map(Entity(_, newState, entityOption.map(_.version).getOrElse(1))) - _ <- if entityOption.isEmpty then stateRepo.insert(newEntity) + newEntity = Entity(entityId, newState, entityOption.map(_.version).getOrElse(1)) + _ <- if entityOption.isEmpty then stateRepo.insert(newEntity.entityId, newEntity) else stateRepo.update(newEntity.entityId, newEntity) eventEntity <- Random.nextUUID.map(Event(newEntity.entityId, event, _)) - _ <- eventRepo.insert(eventEntity) - yield (event, newState) + _ <- eventRepo.insert(eventEntity.eventId, eventEntity) + yield (eventEntity, newEntity) private def transition( command: Command, @@ -54,21 +71,23 @@ class CommandEngine[Command, Event, State]( entityOption: Option[Entity[State]], handler: CommandHandler[Command, Event, State] ): Task[(Event, Option[State])] = (entityOption, handler) match - case (None, h: CommandHandlerCreate[Command, Event, State]) => - h.onCommand(entityId, command) + case (None, h) if !h.isUpdate => + h.asInstanceOf[CommandHandlerCreate[Command, Event]] + .onCommand(entityId, command) .map(event => (event, reducer.reduce(event))) - case (Some(entity), h: CommandHandlerUpdate[Command, Event, State]) => - h.onCommand(entityId, entity.data, command) + case (Some(entity), h) if h.isUpdate => + h.asInstanceOf[CommandHandlerUpdate[Command, Event, State]] + .onCommand(entityId, entity.data, command) .map(event => (event, reducer.reduce(entity.data, event))) - case (Some(_), _: CommandHandlerCreate[Command, Event, State]) => + case (Some(_), h) if !h.isUpdate => ZIO.fail( new IllegalArgumentException(s"State already exists when applying create command $name") ) - case (None, _: CommandHandlerUpdate[Command, Event, State]) => + case (None, h) if h.isUpdate => ZIO.fail(new IllegalArgumentException(s"No state found to apply the update command $name")) case _ => ZIO.fail(new IllegalArgumentException("Impossible state")) end CommandEngine object CommandEngine: - def layer[Command, Event, State](using Tag[Command], Tag[Event], Tag[State]) = + def layer[Command: Tag, Event: Tag, State: Tag] = ZLayer.fromFunction(CommandEngine[Command, Event, State](_, _, _, _)) diff --git a/core/src/lu/foyer/Repository.scala b/core/src/lu/foyer/Repository.scala index 9209818..84e132b 100644 --- a/core/src/lu/foyer/Repository.scala +++ b/core/src/lu/foyer/Repository.scala @@ -1,13 +1,28 @@ package lu.foyer import zio.* +import zio.schema.* import java.util.UUID -final case class Page(number: Int, size: Int, totals: Boolean) +final case class Page(number: Option[Int], size: Option[Int], totals: Option[Boolean]) + derives Schema final case class Paged[T](items: List[T], totals: Option[Long]) trait Repository[Entity, Id]: def fetchOne(id: Id): Task[Option[Entity]] def fetchMany(page: Page): Task[Paged[Entity]] - def insert(entity: Entity): Task[Unit] + def insert(id: Id, entity: Entity): Task[Unit] def update(id: Id, entity: Entity): Task[Unit] + +trait InMemoryRepository[State](entities: Ref[Map[UUID, State]]) extends Repository[State, UUID]: + def fetchOne(id: UUID): Task[Option[State]] = entities.get.map(_.get(id)) + def fetchMany(page: Page): Task[Paged[State]] = entities.get.map(entities => + val items = + entities.values + .drop(page.number.getOrElse(0) * page.size.getOrElse(50)).take(page.size.getOrElse(50)) + Paged(items.toList, if page.totals.getOrElse(false) then Some(entities.size) else None) + ) + def insert(id: UUID, entity: State): Task[Unit] = + entities.update(entities => entities.updated(id, entity)) + def update(id: UUID, entity: State): Task[Unit] = + entities.update(entities => entities.updated(id, entity)) diff --git a/core/src/lu/foyer/clients/ClientHandlers.scala b/core/src/lu/foyer/clients/ClientHandlers.scala new file mode 100644 index 0000000..2e80a41 --- /dev/null +++ b/core/src/lu/foyer/clients/ClientHandlers.scala @@ -0,0 +1,54 @@ +package lu.foyer +package clients + +import zio.* +import java.util.UUID + +object ClientHandlers: + val layer: ULayer[List[CommandHandler[ClientCommand, ClientEvent, ClientState]]] = + ZLayer.succeed( + List( + CreateHandler, + UpdateHandler, + DisableHandler + ) + ) + +object CreateHandler extends CommandHandlerCreate[ClientCommand.Create, ClientEvent.Created]: + val name = "create" + def onCommand(entityId: UUID, command: ClientCommand.Create): Task[ClientEvent.Created] = + ZIO.succeed( + ClientEvent.Created( + command.lastName, + command.firstName, + command.birthDate, + command.drivingLicenseDate, + command.phoneNumber, + command.email, + command.address + ) + ) + +object UpdateHandler + extends CommandHandlerUpdate[ClientCommand.Update, ClientEvent.Updated, ClientState.Actif]: + val name = "update" + def onCommand(entityId: UUID, state: ClientState.Actif, command: ClientCommand.Update) + : Task[ClientEvent.Updated] = + ZIO.succeed( + ClientEvent.Updated( + command.lastName, + command.firstName, + command.birthDate, + command.drivingLicenseDate, + command.phoneNumber, + command.email, + command.address + ) + ) + +object DisableHandler + extends CommandHandlerUpdate[ClientCommand.Disable, ClientEvent.Disabled, ClientState.Actif]: + val name = "disable" + def onCommand(entityId: UUID, state: ClientState.Actif, command: ClientCommand.Disable) + : Task[ClientEvent.Disabled] = + ZIO.succeed(ClientEvent.Disabled(command.reason)) diff --git a/core/src/lu/foyer/clients/ClientReducer.scala b/core/src/lu/foyer/clients/ClientReducer.scala index 47988f7..aa53dbf 100644 --- a/core/src/lu/foyer/clients/ClientReducer.scala +++ b/core/src/lu/foyer/clients/ClientReducer.scala @@ -1,48 +1,16 @@ package lu.foyer package clients -object ClientReducer extends Reducer[ClientEvent, ClientState]: +import zio.* - def reduce(event: ClientEvent): Option[ClientState] = event match - case e: ClientEvent.Created => - Some( - ClientState.Actif( - e.lastName, - e.firstName, - e.birthDate, - e.drivingLicenseDate, - e.phoneNumber, - e.email, - e.address - ) - ) - case _: ClientEvent.Updated => None - case _: ClientEvent.Disabled => None +class ClientReducer() extends Reducer[ClientEvent, ClientState]: - def reduce(state: ClientState, event: ClientEvent): Option[ClientState] = (state, event) match - case (s: ClientState.Actif, e: ClientEvent.Updated) => - Some( - ClientState.Actif( - e.lastName.getOrElse(s.lastName), - e.firstName.getOrElse(s.firstName), - e.birthDate.getOrElse(s.birthDate), - e.drivingLicenseDate.orElse(s.drivingLicenseDate), - e.phoneNumber.orElse(s.phoneNumber), - e.email.orElse(s.email), - e.address.orElse(s.address) - ) - ) - case (s: ClientState.Actif, _: ClientEvent.Disabled) => - Some( - ClientState.Inactif( - s.lastName, - s.firstName, - s.birthDate, - s.drivingLicenseDate, - s.phoneNumber, - s.email, - s.address - ) - ) - case _ => None -end ClientReducer + override val fromEmpty = + case e: ClientEvent.Created => ClientState.create(e) + + override val fromState = + case (s: ClientState.Actif, e: ClientEvent.Updated) => s.update(e) + case (s: ClientState.Actif, e: ClientEvent.Disabled) => s.disable(e) + +object ClientReducer: + val layer: ULayer[Reducer[ClientEvent, ClientState]] = ZLayer.succeed(ClientReducer()) diff --git a/core/src/lu/foyer/clients/ClientState.scala b/core/src/lu/foyer/clients/ClientState.scala index 5f2efe3..3ee8363 100644 --- a/core/src/lu/foyer/clients/ClientState.scala +++ b/core/src/lu/foyer/clients/ClientState.scala @@ -24,3 +24,37 @@ enum ClientState derives Schema: phoneNumber: Option[PhoneNumberInput], email: Option[Email], address: Option[Address]) + +object ClientState: + def create(event: ClientEvent.Created) = + ClientState.Actif( + event.lastName, + event.firstName, + event.birthDate, + event.drivingLicenseDate, + event.phoneNumber, + event.email, + event.address + ) + +extension (s: ClientState.Actif) + def update(e: ClientEvent.Updated) = + ClientState.Actif( + e.lastName.getOrElse(s.lastName), + e.firstName.getOrElse(s.firstName), + e.birthDate.getOrElse(s.birthDate), + e.drivingLicenseDate.orElse(s.drivingLicenseDate), + e.phoneNumber.orElse(s.phoneNumber), + e.email.orElse(s.email), + e.address.orElse(s.address) + ) + def disable(e: ClientEvent.Disabled) = + ClientState.Inactif( + s.lastName, + s.firstName, + s.birthDate, + s.drivingLicenseDate, + s.phoneNumber, + s.email, + s.address + ) diff --git a/flake.nix b/flake.nix index 4975e23..8c06303 100644 --- a/flake.nix +++ b/flake.nix @@ -46,7 +46,7 @@ services = { kafka.enable = true; - mongodb.enable = true; + # mongodb.enable = true; }; } diff --git a/model/src/lu/foyer/clients/Client.scala b/model/src/lu/foyer/clients/Client.scala index a4e8ed2..4328d24 100644 --- a/model/src/lu/foyer/clients/Client.scala +++ b/model/src/lu/foyer/clients/Client.scala @@ -33,7 +33,6 @@ case class Client( address: Address) derives Schema -// TODO validate using libphonenumber case class PhoneNumber( country: Country, nationalNumber: NationalNumber,