Implement clients API

This commit is contained in:
Paul-Henri Froidmont 2025-03-03 00:24:13 +01:00
parent 91584c18d5
commit 31014d1a0c
Signed by: phfroidmont
GPG key ID: BE948AFD7E7873BE
14 changed files with 474 additions and 228 deletions

View file

@ -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](_, _, _, _))

View file

@ -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))

View file

@ -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))

View file

@ -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())

View file

@ -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
)