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

@ -15,8 +15,25 @@ import java.net.URI
import java.time.LocalDate import java.time.LocalDate
import java.util.UUID import java.util.UUID
object App extends ZIOAppDefault: object HttpServer:
val openAPI = OpenAPIGen.fromEndpoints(ClientController.endpoints) def routes =
val routes = ClientController.routes ++ SwaggerUI.routes("docs" / "openapi", openAPI) 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
)

View file

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

View file

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

View file

@ -13,162 +13,12 @@ import java.net.URI
import java.time.LocalDate import java.time.LocalDate
import java.util.UUID import java.util.UUID
object JsonApiResponse: class ClientController(
override val commandEngine: CommandEngine[ClientCommand, ClientEvent, ClientState])
case class One[T]( extends CommandEngineController[ClientCommand, ClientEvent, ClientState](
data: Entity[T], "api:example:insurance",
links: Links) "client"
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))
) )
end CommandEngineController
object ClientController: object ClientController:
private val fetchMany = val layer = ZLayer.fromFunction(ClientController.apply)
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

View file

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

View file

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

View file

@ -1,12 +1,17 @@
// scalafmt: { runner.dialect = scala213 } // scalafmt: { runner.dialect = scala213 }
package build package build
import mill._, scalalib._ import mill._, scalalib._
import coursier.maven.MavenRepository
val sonatypeSnapshots = Seq(
MavenRepository("https://oss.sonatype.org/content/repositories/snapshots")
)
object Versions { object Versions {
val zio = "2.1.15" val zio = "2.1.15"
val zioJson = "0.7.33" val zioJson = "0.7.33"
val zioSchema = "1.6.3" val zioSchema = "1.6.3"
val zioHttp = "3.0.1" val zioHttp = "3.0.1+97-29d12531-SNAPSHOT"
val zioPrelude = "1.0.0-RC39" 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-schema-derivation:${Versions.zioSchema}",
ivy"dev.zio::zio-prelude:${Versions.zioPrelude}" ivy"dev.zio::zio-prelude:${Versions.zioPrelude}"
) )
def repositoriesTask = Task.Anon {
super.repositoriesTask() ++ sonatypeSnapshots
}
} }
object model extends CommonModule object model extends CommonModule

View file

@ -2,25 +2,42 @@ package lu.foyer
import zio.* import zio.*
import java.util.UUID import java.util.UUID
import zio.schema.Schema
final case class Entity[T](entityId: UUID, data: T, version: Long) final case class Entity[T](entityId: UUID, data: T, version: Long)
final case class Event[T](entityId: UUID, data: T, eventId: UUID) final case class Event[T](entityId: UUID, data: T, eventId: UUID)
trait StateRepository[State] extends Repository[Entity[State], UUID] trait StateRepository[Data] extends Repository[Entity[Data], UUID]
trait EventRepository[State] extends Repository[Event[State], 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]: trait Reducer[Event, State]:
def reduce(event: Event): Option[State] def fromEmpty: PartialFunction[Event, State]
def reduce(state: State, event: Event): Option[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 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] 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] def onCommand(entityId: UUID, state: State, command: Command): Task[Event]
val isCreate = false
val commandSchema = summon[Schema[Command]]
class CommandEngine[Command, Event, State]( class CommandEngine[Command, Event, State](
val handlers: List[CommandHandler[Command, Event, State]], val handlers: List[CommandHandler[Command, Event, State]],
@ -28,7 +45,8 @@ class CommandEngine[Command, Event, State](
val eventRepo: EventRepository[Event], val eventRepo: EventRepository[Event],
val stateRepo: StateRepository[State]): 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 for
handler <- ZIO handler <- ZIO
.succeed(handlers.find(_.name == name)) .succeed(handlers.find(_.name == name))
@ -39,13 +57,12 @@ class CommandEngine[Command, Event, State](
ZIO ZIO
.succeed(newStateOption) .succeed(newStateOption)
.someOrFail(new IllegalArgumentException("Reducer cannot resolve state transition")) .someOrFail(new IllegalArgumentException("Reducer cannot resolve state transition"))
newEntity <- newEntity = Entity(entityId, newState, entityOption.map(_.version).getOrElse(1))
Random.nextUUID.map(Entity(_, newState, entityOption.map(_.version).getOrElse(1))) _ <- if entityOption.isEmpty then stateRepo.insert(newEntity.entityId, newEntity)
_ <- if entityOption.isEmpty then stateRepo.insert(newEntity)
else stateRepo.update(newEntity.entityId, newEntity) else stateRepo.update(newEntity.entityId, newEntity)
eventEntity <- Random.nextUUID.map(Event(newEntity.entityId, event, _)) eventEntity <- Random.nextUUID.map(Event(newEntity.entityId, event, _))
_ <- eventRepo.insert(eventEntity) _ <- eventRepo.insert(eventEntity.eventId, eventEntity)
yield (event, newState) yield (eventEntity, newEntity)
private def transition( private def transition(
command: Command, command: Command,
@ -54,21 +71,23 @@ class CommandEngine[Command, Event, State](
entityOption: Option[Entity[State]], entityOption: Option[Entity[State]],
handler: CommandHandler[Command, Event, State] handler: CommandHandler[Command, Event, State]
): Task[(Event, Option[State])] = (entityOption, handler) match ): Task[(Event, Option[State])] = (entityOption, handler) match
case (None, h: CommandHandlerCreate[Command, Event, State]) => case (None, h) if !h.isUpdate =>
h.onCommand(entityId, command) h.asInstanceOf[CommandHandlerCreate[Command, Event]]
.onCommand(entityId, command)
.map(event => (event, reducer.reduce(event))) .map(event => (event, reducer.reduce(event)))
case (Some(entity), h: CommandHandlerUpdate[Command, Event, State]) => case (Some(entity), h) if h.isUpdate =>
h.onCommand(entityId, entity.data, command) h.asInstanceOf[CommandHandlerUpdate[Command, Event, State]]
.onCommand(entityId, entity.data, command)
.map(event => (event, reducer.reduce(entity.data, event))) .map(event => (event, reducer.reduce(entity.data, event)))
case (Some(_), _: CommandHandlerCreate[Command, Event, State]) => case (Some(_), h) if !h.isUpdate =>
ZIO.fail( ZIO.fail(
new IllegalArgumentException(s"State already exists when applying create command $name") 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")) ZIO.fail(new IllegalArgumentException(s"No state found to apply the update command $name"))
case _ => ZIO.fail(new IllegalArgumentException("Impossible state")) case _ => ZIO.fail(new IllegalArgumentException("Impossible state"))
end CommandEngine end CommandEngine
object 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](_, _, _, _)) ZLayer.fromFunction(CommandEngine[Command, Event, State](_, _, _, _))

View file

@ -1,13 +1,28 @@
package lu.foyer package lu.foyer
import zio.* import zio.*
import zio.schema.*
import java.util.UUID 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]) final case class Paged[T](items: List[T], totals: Option[Long])
trait Repository[Entity, Id]: trait Repository[Entity, Id]:
def fetchOne(id: Id): Task[Option[Entity]] def fetchOne(id: Id): Task[Option[Entity]]
def fetchMany(page: Page): Task[Paged[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] 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 lu.foyer
package clients package clients
object ClientReducer extends Reducer[ClientEvent, ClientState]: import zio.*
def reduce(event: ClientEvent): Option[ClientState] = event match class ClientReducer() extends Reducer[ClientEvent, ClientState]:
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
def reduce(state: ClientState, event: ClientEvent): Option[ClientState] = (state, event) match override val fromEmpty =
case (s: ClientState.Actif, e: ClientEvent.Updated) => case e: ClientEvent.Created => ClientState.create(e)
Some(
ClientState.Actif( override val fromState =
e.lastName.getOrElse(s.lastName), case (s: ClientState.Actif, e: ClientEvent.Updated) => s.update(e)
e.firstName.getOrElse(s.firstName), case (s: ClientState.Actif, e: ClientEvent.Disabled) => s.disable(e)
e.birthDate.getOrElse(s.birthDate),
e.drivingLicenseDate.orElse(s.drivingLicenseDate), object ClientReducer:
e.phoneNumber.orElse(s.phoneNumber), val layer: ULayer[Reducer[ClientEvent, ClientState]] = ZLayer.succeed(ClientReducer())
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

View file

@ -24,3 +24,37 @@ enum ClientState derives Schema:
phoneNumber: Option[PhoneNumberInput], phoneNumber: Option[PhoneNumberInput],
email: Option[Email], email: Option[Email],
address: Option[Address]) 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
)

View file

@ -46,7 +46,7 @@
services = { services = {
kafka.enable = true; kafka.enable = true;
mongodb.enable = true; # mongodb.enable = true;
}; };
} }

View file

@ -33,7 +33,6 @@ case class Client(
address: Address) address: Address)
derives Schema derives Schema
// TODO validate using libphonenumber
case class PhoneNumber( case class PhoneNumber(
country: Country, country: Country,
nationalNumber: NationalNumber, nationalNumber: NationalNumber,