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

View file

@ -9,10 +9,15 @@ import zio.http.codec.*
import zio.http.codec.PathCodec.path
import zio.http.endpoint.openapi.OpenAPIGen
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.contracts.*
import zio.schema.codec.JsonCodec.ExplicitConfig
object HttpServer:
@ -28,8 +33,19 @@ object HttpServer:
++ SwaggerUI.routes("docs" / "openapi", openAPI)
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))
)
@ -55,3 +71,4 @@ object App extends ZIOAppDefault:
PremiumServiceImpl.layer,
EmployeeServiceImpl.layer
)
end App

View file

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

View file

@ -6,15 +6,14 @@ import mill.*, scalalib.*
import com.goyeau.mill.scalafix.ScalafixModule
object Versions:
val zio = "2.1.21"
val zio = "2.1.22"
val zioJson = "0.7.44"
val zioSchema = "1.7.5"
val zioHttp = "3.5.1"
val zioPrelude = "1.0.0-RC42"
trait CommonModule extends ScalaModule with ScalafixModule:
def scalaVersion = "3.7.2"
def scalaVersion = "3.7.3"
def scalacOptions = Seq(
"-Wunused:all",
"-preview",
@ -32,17 +31,15 @@ trait CommonModule extends ScalaModule with ScalafixModule:
mvn"dev.zio::zio-prelude:${Versions.zioPrelude}"
)
object model extends CommonModule
object core extends CommonModule:
def moduleDeps = Seq(model)
object api extends CommonModule:
def moduleDeps = Seq(core)
def mvnDeps = Seq(
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[?]
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 commandSchema = summon[Schema[Command]]
trait CommandHandlerUpdate[Command: Schema, 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 commandSchema = summon[Schema[Command]]
@ -45,7 +46,7 @@ class CommandEngine[Command, Event, State](
val stateRepo: StateRepository[State]):
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
handler <- ZIO
.succeed(handlers.find(_.name == name))
@ -57,7 +58,8 @@ class CommandEngine[Command, Event, State](
.succeed(newStateOption)
.someOrFail(new IllegalArgumentException("Reducer cannot resolve state transition"))
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)
eventEntity <- Random.nextUUID.map(id => Event(newEntity.entityId, event, id.toString))
_ <- eventRepo.insert(eventEntity.eventId, eventEntity)
@ -69,7 +71,7 @@ class CommandEngine[Command, Event, State](
entityId: String,
entityOption: Option[Entity[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 =>
h.asInstanceOf[CommandHandlerCreate[Command, Event]]
.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"
def onCommand(entityId: String, command: ContractCommand.Subscribe)
: Task[ContractEvent.Subscribed] =
override def onCommand(entityId: String, command: ContractCommand.Subscribe) =
for
holder <- clientStateRepo.fetchOne(command.holder).someOrFailException
holder <- clientStateRepo
.fetchOne(command.holder)
.someOrFail(JsonApiError.NotFound(command.holder))
residentialAddress <-
holder.data match
case ClientState.Actif(_, _, _, _, _, _, Some(address)) =>
ZIO.succeed(address)
case _ =>
ZIO.fail(new IllegalArgumentException(s"No active holder found for ${command.holder}"))
case ClientState.Actif(address = Some(address)) => ZIO.succeed(address)
case _ => ZIO.fail(JsonApiError.NotFound(command.holder))
premium <-
ZIO
.fromEither(
premiumService.computePremium(command.formula, command.vehicle, residentialAddress)
)
.mapError(new IllegalArgumentException(_))
.mapError(JsonApiError.InternalServerError(_))
yield ContractEvent.Subscribed(
command.product,
command.holder,
@ -52,7 +51,6 @@ class SubscribeHandler(
command.formula,
premium
)
end SubscribeHandler
class AmendHandler(clientStateRepo: StateRepository[ClientState], premiumService: PremiumService)
extends CommandHandlerUpdate[ContractCommand.Amend, ContractEvent.Amended, ContractState]:
@ -65,8 +63,7 @@ class AmendHandler(clientStateRepo: StateRepository[ClientState], premiumService
holder <- clientStateRepo.fetchOne(state.holder).someOrFailException
residentialAddress <-
holder.data match
case ClientState.Actif(_, _, _, _, _, _, Some(address)) =>
ZIO.succeed(address)
case ClientState.Actif(address = Some(address)) => ZIO.succeed(address)
case _ =>
ZIO.fail(new IllegalArgumentException(s"No active holder found for ${state.holder}"))
premium <- ZIO
@ -84,7 +81,6 @@ class AmendHandler(clientStateRepo: StateRepository[ClientState], premiumService
command.formula.getOrElse(state.formula),
premium
)
end AmendHandler
class ApproveHandler(employeeService: EmployeeService)
extends CommandHandlerUpdate[