Add commands engine

This commit is contained in:
Paul-Henri Froidmont 2025-02-28 05:40:32 +01:00
parent 1919e4b72c
commit 91584c18d5
Signed by: phfroidmont
GPG key ID: BE948AFD7E7873BE
9 changed files with 336 additions and 58 deletions

View file

@ -1,71 +1,22 @@
package lu.foyer
import lu.foyer.clients.ClientState
import lu.foyer.clients.*
import zio.*
import zio.Console.*
import zio.http.*
import zio.schema.*
import zio.http.endpoint.*
import zio.http.codec.*
import java.net.URI
import zio.http.codec.PathCodec.path
import zio.http.endpoint.*
import zio.http.endpoint.openapi.OpenAPIGen
import zio.http.endpoint.openapi.SwaggerUI
import zio.http.codec.PathCodec.path
import zio.schema.*
case class JsonApiResponseSingle[T](
data: JsonApiResponseEntity[T],
links: JsonApiResponseLinks)
derives Schema
case class JsonApiResponseMultiple[T](
data: List[JsonApiResponseEntity[T]],
links: JsonApiResponseLinks,
meta: JsonApiResponseMeta)
derives Schema
case class JsonApiResponseLinks(
self: String,
first: Option[String] = None,
prev: Option[String] = None,
next: Option[String] = None,
last: Option[String] = None)
derives Schema
case class JsonApiResponseMeta(totalRecords: Int, totalPages: Int) derives Schema
final case class JsonApiResponseEntity[T](id: String, `type`: String, attributes: T) derives Schema
case class Page(number: Int, size: Int, totals: Boolean)
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)))
object ClientsController:
private val fetchMany =
Endpoint(Method.GET / "clients").query(pageParams).out[JsonApiResponseMultiple[ClientState]]
private val fetchManyRoute =
fetchMany.implement(page =>
ZIO.succeed(
JsonApiResponseMultiple[ClientState](
List.empty,
JsonApiResponseLinks("https://api.example.org"),
meta = JsonApiResponseMeta(0, 1)
)
)
)
val endpoints = List(fetchMany)
val routes = Routes(fetchManyRoute)
import java.net.URI
import java.time.LocalDate
import java.util.UUID
object App extends ZIOAppDefault:
val openAPI = OpenAPIGen.fromEndpoints(ClientsController.endpoints)
val routes = ClientsController.routes ++ SwaggerUI.routes("docs" / "openapi", openAPI)
val openAPI = OpenAPIGen.fromEndpoints(ClientController.endpoints)
val routes = ClientController.routes ++ SwaggerUI.routes("docs" / "openapi", openAPI)
override def run = Server.serve(routes).provide(Server.default)

View file

@ -0,0 +1,174 @@
package lu.foyer
package clients
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
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))
)
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

View file

@ -0,0 +1,74 @@
package lu.foyer
import zio.*
import java.util.UUID
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 Reducer[Event, State]:
def reduce(event: Event): Option[State]
def reduce(state: State, event: Event): Option[State]
trait CommandHandler[Command, Event, State]:
def name: String
trait CommandHandlerCreate[Command, Event, State] extends CommandHandler[Command, Event, State]:
def onCommand(entityId: UUID, command: Command): Task[Event]
trait CommandHandlerUpdate[Command, Event, State] extends CommandHandler[Command, Event, State]:
def onCommand(entityId: UUID, state: State, command: Command): Task[Event]
class CommandEngine[Command, Event, State](
val handlers: List[CommandHandler[Command, Event, State]],
reducer: Reducer[Event, State],
val eventRepo: EventRepository[Event],
val stateRepo: StateRepository[State]):
def handleCommand(command: Command, name: String, entityId: UUID): Task[(Event, State)] =
for
handler <- ZIO
.succeed(handlers.find(_.name == name))
.someOrFail(new IllegalArgumentException(s"No handler found for command $name"))
entityOption <- stateRepo.fetchOne(entityId)
(event, newStateOption) <- transition(command, name, entityId, entityOption, handler)
newState <-
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)
else stateRepo.update(newEntity.entityId, newEntity)
eventEntity <- Random.nextUUID.map(Event(newEntity.entityId, event, _))
_ <- eventRepo.insert(eventEntity)
yield (event, newState)
private def transition(
command: Command,
name: String,
entityId: UUID,
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)
.map(event => (event, reducer.reduce(event)))
case (Some(entity), h: CommandHandlerUpdate[Command, Event, State]) =>
h.onCommand(entityId, entity.data, command)
.map(event => (event, reducer.reduce(entity.data, event)))
case (Some(_), _: CommandHandlerCreate[Command, Event, State]) =>
ZIO.fail(
new IllegalArgumentException(s"State already exists when applying create command $name")
)
case (None, _: CommandHandlerUpdate[Command, Event, State]) =>
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]) =
ZLayer.fromFunction(CommandEngine[Command, Event, State](_, _, _, _))

View file

@ -0,0 +1,13 @@
package lu.foyer
import zio.*
import java.util.UUID
final case class Page(number: Int, size: Int, totals: Boolean)
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 update(id: Id, entity: Entity): Task[Unit]

View file

@ -9,6 +9,7 @@ enum ClientCommand derives Schema:
case Create(
lastName: ClientLastName,
firstName: ClientFirstName,
birthDate: ClientBirthDate,
drivingLicenseDate: Option[ClientDrivingLicenseDate],
phoneNumber: Option[PhoneNumberInput],
email: Option[Email],
@ -16,8 +17,14 @@ enum ClientCommand derives Schema:
case Update(
lastName: Option[ClientLastName],
firstName: Option[ClientFirstName],
birthDate: Option[ClientBirthDate],
drivingLicenseDate: Option[ClientDrivingLicenseDate],
phoneNumber: Option[PhoneNumberInput],
email: Option[Email],
address: Option[Address])
case Disable(reason: ClientDisabledReason)
object ClientCommand:
given Schema[ClientCommand.Create] = DeriveSchema.gen
given Schema[ClientCommand.Update] = DeriveSchema.gen
given Schema[ClientCommand.Disable] = DeriveSchema.gen

View file

@ -4,7 +4,9 @@ package clients
import zio.schema.*
import java.time.LocalDate
import zio.schema.annotation.discriminatorName
@discriminatorName("eventType")
enum ClientEvent derives Schema:
case Created(
lastName: ClientLastName,
@ -22,3 +24,9 @@ enum ClientEvent derives Schema:
phoneNumber: Option[PhoneNumberInput],
email: Option[Email],
address: Option[Address])
case Disabled(reason: ClientDisabledReason)
object ClientEvent:
given Schema[ClientEvent.Created] = DeriveSchema.gen
given Schema[ClientEvent.Updated] = DeriveSchema.gen
given Schema[ClientEvent.Disabled] = DeriveSchema.gen

View file

@ -0,0 +1,48 @@
package lu.foyer
package clients
object ClientReducer extends Reducer[ClientEvent, ClientState]:
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
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

View file

@ -2,9 +2,11 @@ package lu.foyer
package clients
import zio.schema.*
import zio.schema.annotation.discriminatorName
import java.time.LocalDate
@discriminatorName("statusType")
enum ClientState derives Schema:
case Actif(
lastName: ClientLastName,

View file

@ -16,6 +16,7 @@ trait RefinedString[New <: String] extends RefinedType[String, New]:
)
trait RefinedLocalDate[New <: LocalDate] extends RefinedType[LocalDate, New]:
def apply(value: LocalDate): New = assume(value)
override def validation(value: LocalDate): Validation[String, New] =
Validation.succeed(assume(value))
given Schema[New] = Schema[LocalDate].transform(assume, identity)