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.util.UUID
|
||||
|
||||
object App extends ZIOAppDefault:
|
||||
val openAPI = OpenAPIGen.fromEndpoints(ClientController.endpoints)
|
||||
val routes = ClientController.routes ++ SwaggerUI.routes("docs" / "openapi", openAPI)
|
||||
object HttpServer:
|
||||
def routes =
|
||||
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.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))
|
||||
class ClientController(
|
||||
override val commandEngine: CommandEngine[ClientCommand, ClientEvent, ClientState])
|
||||
extends CommandEngineController[ClientCommand, ClientEvent, ClientState](
|
||||
"api:example:insurance",
|
||||
"client"
|
||||
)
|
||||
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
|
||||
val layer = ZLayer.fromFunction(ClientController.apply)
|
||||
|
|
|
|||
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(_)))
|
||||
Loading…
Add table
Add a link
Reference in a new issue