Improve error handling

This commit is contained in:
Paul-Henri Froidmont 2025-10-22 15:30:36 +02:00
parent 87bd780f9f
commit e6a8150483
Signed by: phfroidmont
GPG key ID: BE948AFD7E7873BE
7 changed files with 78 additions and 62 deletions

View file

@ -1,4 +1,4 @@
version = "3.8.3" version = "3.10.1"
preset=defaultWithAlign preset=defaultWithAlign

View file

@ -9,10 +9,15 @@ import zio.http.codec.*
import zio.http.codec.PathCodec.path import zio.http.codec.PathCodec.path
import zio.http.endpoint.openapi.OpenAPIGen import zio.http.endpoint.openapi.OpenAPIGen
import zio.http.endpoint.openapi.SwaggerUI import zio.http.endpoint.openapi.SwaggerUI
import zio.logging.ConsoleLoggerConfig
import zio.logging.LogColor
import zio.logging.LogFilter
import zio.logging.LogFormat.*
import zio.logging.consoleLogger
import zio.schema.codec.JsonCodec.ExplicitConfig
import lu.foyer.clients.* import lu.foyer.clients.*
import lu.foyer.contracts.* import lu.foyer.contracts.*
import zio.schema.codec.JsonCodec.ExplicitConfig
object HttpServer: object HttpServer:
@ -28,8 +33,19 @@ object HttpServer:
++ SwaggerUI.routes("docs" / "openapi", openAPI) ++ SwaggerUI.routes("docs" / "openapi", openAPI)
object App extends ZIOAppDefault: object App extends ZIOAppDefault:
private val logFormat =
label("timestamp", timestamp.fixed(32)).color(LogColor.BLUE) |-|
label("level", level.fixed(5)).highlight |-|
label("thread", fiberId).color(LogColor.WHITE) |-|
label("message", quoted(line)).highlight |-|
cause
override val bootstrap = CodecConfig.configLayer( val logFilter = LogFilter.LogLevelByNameConfig(LogLevel.Debug)
override val bootstrap =
Runtime.removeDefaultLoggers >>>
consoleLogger(ConsoleLoggerConfig(logFormat, logFilter)) >>>
CodecConfig.configLayer(
CodecConfig(explicitNulls = ExplicitConfig(encoding = false, decoding = false)) CodecConfig(explicitNulls = ExplicitConfig(encoding = false, decoding = false))
) )
@ -55,3 +71,4 @@ object App extends ZIOAppDefault:
PremiumServiceImpl.layer, PremiumServiceImpl.layer,
EmployeeServiceImpl.layer EmployeeServiceImpl.layer
) )
end App

View file

@ -5,7 +5,6 @@ import zio.http.*
import zio.http.codec.* import zio.http.codec.*
import zio.http.endpoint.* import zio.http.endpoint.*
import zio.schema.* import zio.schema.*
import zio.schema.annotation.discriminatorName
import lu.foyer.JsonApiResponse.Many import lu.foyer.JsonApiResponse.Many
import lu.foyer.JsonApiResponse.One import lu.foyer.JsonApiResponse.One
@ -36,15 +35,6 @@ object JsonApiResponse:
case class RelationshipsData(id: String, `type`: String) derives Schema case class RelationshipsData(id: String, `type`: String) derives Schema
case class RelationshipsLinks(related: String) derives Schema case class RelationshipsLinks(related: String) derives Schema
@discriminatorName("errorType")
enum Error derives Schema:
case NotFound(id: String)
case InternalServerError(title: String)
object Error:
given Schema[Error.NotFound] = DeriveSchema.gen
given Schema[Error.InternalServerError] = DeriveSchema.gen
end JsonApiResponse
final case class ProxyHeaders( final case class ProxyHeaders(
protocol: Option[String], protocol: Option[String],
host: Option[String], host: Option[String],
@ -80,26 +70,26 @@ trait JsonApiController:
endpoint: Endpoint[PathInput, Input, ZNothing, ZNothing, AuthType.None] endpoint: Endpoint[PathInput, Input, ZNothing, ZNothing, AuthType.None]
) )
inline def jsonApiOne[Output: Schema] inline def jsonApiOne[Output: Schema]
: Endpoint[PathInput, Input, JsonApiResponse.Error, One[Output], AuthType.None.type] = : Endpoint[PathInput, Input, JsonApiError, One[Output], AuthType.None.type] =
jsonApiOneWithStatus(Status.Ok) jsonApiOneWithStatus(Status.Ok)
def jsonApiOneWithStatus[Output: Schema](status: Status) = def jsonApiOneWithStatus[Output: Schema](status: Status) =
endpoint endpoint
.out[JsonApiResponse.One[Output]](status) .out[JsonApiResponse.One[Output]](status)
.outErrors[JsonApiResponse.Error]( .outErrors[JsonApiError](
HttpCodec.error[JsonApiResponse.Error.NotFound](Status.NotFound), HttpCodec.error[JsonApiError.NotFound](Status.NotFound),
HttpCodec.error[JsonApiResponse.Error.InternalServerError](Status.InternalServerError) HttpCodec.error[JsonApiError.InternalServerError](Status.InternalServerError)
) )
def jsonApiMany[Output: Schema] = def jsonApiMany[Output: Schema] =
endpoint endpoint
.out[JsonApiResponse.Many[Output]] .out[JsonApiResponse.Many[Output]]
.outError[JsonApiResponse.Error](Status.InternalServerError) .outError[JsonApiError](Status.InternalServerError)
extension [PathInput, Input, Output, Auth <: AuthType]( extension [PathInput, Input, Output, Auth <: AuthType](
endpoint: Endpoint[PathInput, Input, JsonApiResponse.Error, One[Output], AuthType.None] endpoint: Endpoint[PathInput, Input, JsonApiError, One[Output], AuthType.None]
) )
def implementJsonApiOne[Env, A]( def implementJsonApiOne[Env, A](
f: Input => RIO[Env, Option[A]], f: Input => ZIO[Env, JsonApiError | Throwable, Option[A]],
getId: A => String, getId: A => String,
getEntity: A => Output, getEntity: A => Output,
onthology: String = this.onthology, onthology: String = this.onthology,
@ -113,11 +103,12 @@ trait JsonApiController:
endpoint.implement(input => endpoint.implement(input =>
for for
item <- f(input) item <- f(input)
.mapError(e => JsonApiResponse.Error.InternalServerError(e.getMessage)) .tapErrorCause(ZIO.logErrorCause(_))
.flatMap { .mapError {
case Some(paged) => ZIO.succeed(paged) case e: Throwable => JsonApiError.InternalServerError(e.getMessage)
case None => ZIO.fail(JsonApiResponse.Error.NotFound(input.toString)) case e: JsonApiError => e
} }
.someOrFail(JsonApiError.NotFound(input.toString))
headers <- ZIO.service[ProxyHeaders] headers <- ZIO.service[ProxyHeaders]
yield JsonApiResponse.One( yield JsonApiResponse.One(
JsonApiResponse.Entity( JsonApiResponse.Entity(
@ -146,7 +137,7 @@ trait JsonApiController:
) )
def implementJsonApiOneEvent[Env]( def implementJsonApiOneEvent[Env](
f: Input => RIO[Env, Option[Event[Output]]] f: Input => ZIO[Env, JsonApiError | Throwable, Option[Event[Output]]]
)(implicit trace: Trace )(implicit trace: Trace
): Route[Env & ProxyHeaders, Nothing] = ): Route[Env & ProxyHeaders, Nothing] =
implementJsonApiOne( implementJsonApiOne(
@ -168,7 +159,7 @@ trait JsonApiController:
end extension end extension
extension [PathInput, Input, Output, Auth <: AuthType]( extension [PathInput, Input, Output, Auth <: AuthType](
endpoint: Endpoint[PathInput, Input, JsonApiResponse.Error, Many[Output], AuthType.None] endpoint: Endpoint[PathInput, Input, JsonApiError, Many[Output], AuthType.None]
) )
def implementJsonApiMany[Env, A]( def implementJsonApiMany[Env, A](
f: Input => RIO[Env, Paged[A]], f: Input => RIO[Env, Paged[A]],
@ -205,7 +196,8 @@ trait JsonApiController:
page = JsonApiResponse.Page(number = 0, size = 10) page = JsonApiResponse.Page(number = 0, size = 10)
) )
)) ))
.mapError(e => JsonApiResponse.Error.InternalServerError(e.getMessage)) .tapErrorCause(ZIO.logErrorCause(_))
.mapError(e => JsonApiError.InternalServerError(e.getMessage))
) )
inline def implementJsonApiManyEntity[Env]( inline def implementJsonApiManyEntity[Env](

View file

@ -6,15 +6,14 @@ import mill.*, scalalib.*
import com.goyeau.mill.scalafix.ScalafixModule import com.goyeau.mill.scalafix.ScalafixModule
object Versions: object Versions:
val zio = "2.1.21" val zio = "2.1.22"
val zioJson = "0.7.44" val zioJson = "0.7.44"
val zioSchema = "1.7.5" val zioSchema = "1.7.5"
val zioHttp = "3.5.1" val zioHttp = "3.5.1"
val zioPrelude = "1.0.0-RC42" val zioPrelude = "1.0.0-RC42"
trait CommonModule extends ScalaModule with ScalafixModule: trait CommonModule extends ScalaModule with ScalafixModule:
def scalaVersion = "3.7.2" def scalaVersion = "3.7.3"
def scalacOptions = Seq( def scalacOptions = Seq(
"-Wunused:all", "-Wunused:all",
"-preview", "-preview",
@ -32,17 +31,15 @@ trait CommonModule extends ScalaModule with ScalafixModule:
mvn"dev.zio::zio-prelude:${Versions.zioPrelude}" mvn"dev.zio::zio-prelude:${Versions.zioPrelude}"
) )
object model extends CommonModule object model extends CommonModule
object core extends CommonModule: object core extends CommonModule:
def moduleDeps = Seq(model) def moduleDeps = Seq(model)
object api extends CommonModule: object api extends CommonModule:
def moduleDeps = Seq(core) def moduleDeps = Seq(core)
def mvnDeps = Seq( def mvnDeps = Seq(
mvn"dev.zio::zio:${Versions.zio}", mvn"dev.zio::zio:${Versions.zio}",
mvn"dev.zio::zio-http:${Versions.zioHttp}" mvn"dev.zio::zio-http:${Versions.zioHttp}",
mvn"dev.zio::zio-logging:2.5.1"
) )

View file

@ -28,13 +28,14 @@ trait CommandHandler[+Command, +Event, +State]:
def commandSchema: Schema[?] def commandSchema: Schema[?]
trait CommandHandlerCreate[Command: Schema, Event] extends CommandHandler[Command, Event, Nothing]: trait CommandHandlerCreate[Command: Schema, Event] extends CommandHandler[Command, Event, Nothing]:
def onCommand(entityId: String, command: Command): Task[Event] def onCommand(entityId: String, command: Command): IO[JsonApiError | Throwable, Event]
val isCreate = true val isCreate = true
val commandSchema = summon[Schema[Command]] val commandSchema = summon[Schema[Command]]
trait CommandHandlerUpdate[Command: Schema, Event, State] trait CommandHandlerUpdate[Command: Schema, Event, State]
extends CommandHandler[Command, Event, State]: extends CommandHandler[Command, Event, State]:
def onCommand(entityId: String, state: State, command: Command): Task[Event] def onCommand(entityId: String, state: State, command: Command)
: IO[JsonApiError | Throwable, Event]
val isCreate = false val isCreate = false
val commandSchema = summon[Schema[Command]] val commandSchema = summon[Schema[Command]]
@ -45,7 +46,7 @@ class CommandEngine[Command, Event, State](
val stateRepo: StateRepository[State]): val stateRepo: StateRepository[State]):
def handleCommand(command: Command, name: String, entityId: String) def handleCommand(command: Command, name: String, entityId: String)
: Task[(lu.foyer.Event[Event], lu.foyer.Entity[State])] = : IO[JsonApiError | Throwable, (lu.foyer.Event[Event], lu.foyer.Entity[State])] =
for for
handler <- ZIO handler <- ZIO
.succeed(handlers.find(_.name == name)) .succeed(handlers.find(_.name == name))
@ -57,7 +58,8 @@ class CommandEngine[Command, Event, State](
.succeed(newStateOption) .succeed(newStateOption)
.someOrFail(new IllegalArgumentException("Reducer cannot resolve state transition")) .someOrFail(new IllegalArgumentException("Reducer cannot resolve state transition"))
newEntity = Entity(entityId, newState, entityOption.map(_.version).getOrElse(1)) newEntity = Entity(entityId, newState, entityOption.map(_.version).getOrElse(1))
_ <- if entityOption.isEmpty then stateRepo.insert(newEntity.entityId, newEntity) _ <-
if entityOption.isEmpty then stateRepo.insert(newEntity.entityId, newEntity)
else stateRepo.update(newEntity.entityId, newEntity) else stateRepo.update(newEntity.entityId, newEntity)
eventEntity <- Random.nextUUID.map(id => Event(newEntity.entityId, event, id.toString)) eventEntity <- Random.nextUUID.map(id => Event(newEntity.entityId, event, id.toString))
_ <- eventRepo.insert(eventEntity.eventId, eventEntity) _ <- eventRepo.insert(eventEntity.eventId, eventEntity)
@ -69,7 +71,7 @@ class CommandEngine[Command, Event, State](
entityId: String, entityId: String,
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 ): IO[JsonApiError | Throwable, (Event, Option[State])] = (entityOption, handler) match
case (None, h) if !h.isUpdate => case (None, h) if !h.isUpdate =>
h.asInstanceOf[CommandHandlerCreate[Command, Event]] h.asInstanceOf[CommandHandlerCreate[Command, Event]]
.onCommand(entityId, command) .onCommand(entityId, command)

View file

@ -0,0 +1,12 @@
package lu.foyer
import zio.schema.*
import zio.schema.annotation.discriminatorName
@discriminatorName("errorType")
enum JsonApiError derives Schema:
case NotFound(id: String)
case InternalServerError(e: String)
object JsonApiError:
given Schema[JsonApiError.NotFound] = DeriveSchema.gen
given Schema[JsonApiError.InternalServerError] = DeriveSchema.gen

View file

@ -29,22 +29,21 @@ class SubscribeHandler(
val name = "create" val name = "create"
def onCommand(entityId: String, command: ContractCommand.Subscribe) override def onCommand(entityId: String, command: ContractCommand.Subscribe) =
: Task[ContractEvent.Subscribed] =
for for
holder <- clientStateRepo.fetchOne(command.holder).someOrFailException holder <- clientStateRepo
.fetchOne(command.holder)
.someOrFail(JsonApiError.NotFound(command.holder))
residentialAddress <- residentialAddress <-
holder.data match holder.data match
case ClientState.Actif(_, _, _, _, _, _, Some(address)) => case ClientState.Actif(address = Some(address)) => ZIO.succeed(address)
ZIO.succeed(address) case _ => ZIO.fail(JsonApiError.NotFound(command.holder))
case _ =>
ZIO.fail(new IllegalArgumentException(s"No active holder found for ${command.holder}"))
premium <- premium <-
ZIO ZIO
.fromEither( .fromEither(
premiumService.computePremium(command.formula, command.vehicle, residentialAddress) premiumService.computePremium(command.formula, command.vehicle, residentialAddress)
) )
.mapError(new IllegalArgumentException(_)) .mapError(JsonApiError.InternalServerError(_))
yield ContractEvent.Subscribed( yield ContractEvent.Subscribed(
command.product, command.product,
command.holder, command.holder,
@ -52,7 +51,6 @@ class SubscribeHandler(
command.formula, command.formula,
premium premium
) )
end SubscribeHandler
class AmendHandler(clientStateRepo: StateRepository[ClientState], premiumService: PremiumService) class AmendHandler(clientStateRepo: StateRepository[ClientState], premiumService: PremiumService)
extends CommandHandlerUpdate[ContractCommand.Amend, ContractEvent.Amended, ContractState]: extends CommandHandlerUpdate[ContractCommand.Amend, ContractEvent.Amended, ContractState]:
@ -65,8 +63,7 @@ class AmendHandler(clientStateRepo: StateRepository[ClientState], premiumService
holder <- clientStateRepo.fetchOne(state.holder).someOrFailException holder <- clientStateRepo.fetchOne(state.holder).someOrFailException
residentialAddress <- residentialAddress <-
holder.data match holder.data match
case ClientState.Actif(_, _, _, _, _, _, Some(address)) => case ClientState.Actif(address = Some(address)) => ZIO.succeed(address)
ZIO.succeed(address)
case _ => case _ =>
ZIO.fail(new IllegalArgumentException(s"No active holder found for ${state.holder}")) ZIO.fail(new IllegalArgumentException(s"No active holder found for ${state.holder}"))
premium <- ZIO premium <- ZIO
@ -84,7 +81,6 @@ class AmendHandler(clientStateRepo: StateRepository[ClientState], premiumService
command.formula.getOrElse(state.formula), command.formula.getOrElse(state.formula),
premium premium
) )
end AmendHandler
class ApproveHandler(employeeService: EmployeeService) class ApproveHandler(employeeService: EmployeeService)
extends CommandHandlerUpdate[ extends CommandHandlerUpdate[