From 91584c18d530612e6f416f1412b4f14b8bfc6573 Mon Sep 17 00:00:00 2001 From: Paul-Henri Froidmont Date: Fri, 28 Feb 2025 05:40:32 +0100 Subject: [PATCH] Add commands engine --- api/src/lu/foyer/App.scala | 67 +------ .../lu/foyer/clients/ClientController.scala | 174 ++++++++++++++++++ core/src/lu/foyer/EventSourcing.scala | 74 ++++++++ core/src/lu/foyer/Repository.scala | 13 ++ core/src/lu/foyer/clients/ClientCommand.scala | 7 + core/src/lu/foyer/clients/ClientEvent.scala | 8 + core/src/lu/foyer/clients/ClientReducer.scala | 48 +++++ core/src/lu/foyer/clients/ClientState.scala | 2 + model/src/lu/foyer/RefinedType.scala | 1 + 9 files changed, 336 insertions(+), 58 deletions(-) create mode 100644 api/src/lu/foyer/clients/ClientController.scala create mode 100644 core/src/lu/foyer/EventSourcing.scala create mode 100644 core/src/lu/foyer/Repository.scala create mode 100644 core/src/lu/foyer/clients/ClientReducer.scala diff --git a/api/src/lu/foyer/App.scala b/api/src/lu/foyer/App.scala index 40c40e4..3a0e69b 100644 --- a/api/src/lu/foyer/App.scala +++ b/api/src/lu/foyer/App.scala @@ -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) diff --git a/api/src/lu/foyer/clients/ClientController.scala b/api/src/lu/foyer/clients/ClientController.scala new file mode 100644 index 0000000..d2b69a9 --- /dev/null +++ b/api/src/lu/foyer/clients/ClientController.scala @@ -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 diff --git a/core/src/lu/foyer/EventSourcing.scala b/core/src/lu/foyer/EventSourcing.scala new file mode 100644 index 0000000..af9aa0f --- /dev/null +++ b/core/src/lu/foyer/EventSourcing.scala @@ -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](_, _, _, _)) diff --git a/core/src/lu/foyer/Repository.scala b/core/src/lu/foyer/Repository.scala new file mode 100644 index 0000000..9209818 --- /dev/null +++ b/core/src/lu/foyer/Repository.scala @@ -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] diff --git a/core/src/lu/foyer/clients/ClientCommand.scala b/core/src/lu/foyer/clients/ClientCommand.scala index 49b1933..44355db 100644 --- a/core/src/lu/foyer/clients/ClientCommand.scala +++ b/core/src/lu/foyer/clients/ClientCommand.scala @@ -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 diff --git a/core/src/lu/foyer/clients/ClientEvent.scala b/core/src/lu/foyer/clients/ClientEvent.scala index 40749d1..85dcbda 100644 --- a/core/src/lu/foyer/clients/ClientEvent.scala +++ b/core/src/lu/foyer/clients/ClientEvent.scala @@ -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 diff --git a/core/src/lu/foyer/clients/ClientReducer.scala b/core/src/lu/foyer/clients/ClientReducer.scala new file mode 100644 index 0000000..47988f7 --- /dev/null +++ b/core/src/lu/foyer/clients/ClientReducer.scala @@ -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 diff --git a/core/src/lu/foyer/clients/ClientState.scala b/core/src/lu/foyer/clients/ClientState.scala index 3bef782..5f2efe3 100644 --- a/core/src/lu/foyer/clients/ClientState.scala +++ b/core/src/lu/foyer/clients/ClientState.scala @@ -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, diff --git a/model/src/lu/foyer/RefinedType.scala b/model/src/lu/foyer/RefinedType.scala index 74d0c02..23b8776 100644 --- a/model/src/lu/foyer/RefinedType.scala +++ b/model/src/lu/foyer/RefinedType.scala @@ -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)