Implement clients API
This commit is contained in:
parent
91584c18d5
commit
31014d1a0c
14 changed files with 474 additions and 228 deletions
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
|
||||||
104
api/src/lu/foyer/CommandEngineController.scala
Normal file
104
api/src/lu/foyer/CommandEngineController.scala
Normal 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
|
||||||
143
api/src/lu/foyer/JsonApiController.scala
Normal file
143
api/src/lu/foyer/JsonApiController.scala
Normal 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
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
23
api/src/lu/foyer/clients/ClientEventRepositoryInMemory.scala
Normal file
23
api/src/lu/foyer/clients/ClientEventRepositoryInMemory.scala
Normal 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(_)))
|
||||||
12
api/src/lu/foyer/clients/ClientStateRepositoryInMemory.scala
Normal file
12
api/src/lu/foyer/clients/ClientStateRepositoryInMemory.scala
Normal 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(_)))
|
||||||
10
build.sc
10
build.sc
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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](_, _, _, _))
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
54
core/src/lu/foyer/clients/ClientHandlers.scala
Normal file
54
core/src/lu/foyer/clients/ClientHandlers.scala
Normal 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))
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@
|
||||||
|
|
||||||
services = {
|
services = {
|
||||||
kafka.enable = true;
|
kafka.enable = true;
|
||||||
mongodb.enable = true;
|
# mongodb.enable = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue