From e6a8150483b4744ca6a6d37cd5f16a20653b633c Mon Sep 17 00:00:00 2001 From: Paul-Henri Froidmont Date: Wed, 22 Oct 2025 15:30:36 +0200 Subject: [PATCH] Improve error handling --- .scalafmt.conf | 2 +- api/src/lu/foyer/App.scala | 25 ++++++++++-- api/src/lu/foyer/JsonApiController.scala | 40 ++++++++----------- build.mill | 17 ++++---- core/src/lu/foyer/EventSourcing.scala | 16 ++++---- core/src/lu/foyer/JsonApiError.scala | 12 ++++++ .../lu/foyer/contracts/ContractHandlers.scala | 28 ++++++------- 7 files changed, 78 insertions(+), 62 deletions(-) create mode 100644 core/src/lu/foyer/JsonApiError.scala diff --git a/.scalafmt.conf b/.scalafmt.conf index f97a335..7f56b10 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -1,4 +1,4 @@ -version = "3.8.3" +version = "3.10.1" preset=defaultWithAlign diff --git a/api/src/lu/foyer/App.scala b/api/src/lu/foyer/App.scala index 6d3eadc..0800da6 100644 --- a/api/src/lu/foyer/App.scala +++ b/api/src/lu/foyer/App.scala @@ -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,10 +33,21 @@ 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( - CodecConfig(explicitNulls = ExplicitConfig(encoding = false, decoding = false)) - ) + val logFilter = LogFilter.LogLevelByNameConfig(LogLevel.Debug) + + override val bootstrap = + Runtime.removeDefaultLoggers >>> + consoleLogger(ConsoleLoggerConfig(logFormat, logFilter)) >>> + CodecConfig.configLayer( + CodecConfig(explicitNulls = ExplicitConfig(encoding = false, decoding = false)) + ) val app = for @@ -55,3 +71,4 @@ object App extends ZIOAppDefault: PremiumServiceImpl.layer, EmployeeServiceImpl.layer ) +end App diff --git a/api/src/lu/foyer/JsonApiController.scala b/api/src/lu/foyer/JsonApiController.scala index 645d271..5fdda84 100644 --- a/api/src/lu/foyer/JsonApiController.scala +++ b/api/src/lu/foyer/JsonApiController.scala @@ -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]( diff --git a/build.mill b/build.mill index 025d4db..bba8ec8 100644 --- a/build.mill +++ b/build.mill @@ -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 : +object core extends CommonModule: def moduleDeps = Seq(model) - -object api extends CommonModule : +object api extends CommonModule: def moduleDeps = Seq(core) - def mvnDeps = Seq( + 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" ) - diff --git a/core/src/lu/foyer/EventSourcing.scala b/core/src/lu/foyer/EventSourcing.scala index aaf304c..8d765d0 100644 --- a/core/src/lu/foyer/EventSourcing.scala +++ b/core/src/lu/foyer/EventSourcing.scala @@ -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,20 +46,21 @@ 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)) .someOrFail(new IllegalArgumentException(s"No handler found for command $name")) entityOption <- stateRepo.fetchOne(entityId) (event, newStateOption) <- transition(command, name, entityId, entityOption, handler) - newState <- + newState <- ZIO .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) - else stateRepo.update(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) yield (eventEntity, newEntity) @@ -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) diff --git a/core/src/lu/foyer/JsonApiError.scala b/core/src/lu/foyer/JsonApiError.scala new file mode 100644 index 0000000..8339373 --- /dev/null +++ b/core/src/lu/foyer/JsonApiError.scala @@ -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 diff --git a/core/src/lu/foyer/contracts/ContractHandlers.scala b/core/src/lu/foyer/contracts/ContractHandlers.scala index cb377f1..fb139f8 100644 --- a/core/src/lu/foyer/contracts/ContractHandlers.scala +++ b/core/src/lu/foyer/contracts/ContractHandlers.scala @@ -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]: @@ -62,12 +60,11 @@ class AmendHandler(clientStateRepo: StateRepository[ClientState], premiumService def onCommand(entityId: String, state: ContractState, command: ContractCommand.Amend) : Task[ContractEvent.Amended] = for - holder <- clientStateRepo.fetchOne(state.holder).someOrFailException + holder <- clientStateRepo.fetchOne(state.holder).someOrFailException residentialAddress <- holder.data match - case ClientState.Actif(_, _, _, _, _, _, Some(address)) => - ZIO.succeed(address) - case _ => + case ClientState.Actif(address = Some(address)) => ZIO.succeed(address) + case _ => ZIO.fail(new IllegalArgumentException(s"No active holder found for ${state.holder}")) premium <- ZIO .fromEither( @@ -84,7 +81,6 @@ class AmendHandler(clientStateRepo: StateRepository[ClientState], premiumService command.formula.getOrElse(state.formula), premium ) -end AmendHandler class ApproveHandler(employeeService: EmployeeService) extends CommandHandlerUpdate[ @@ -98,7 +94,7 @@ class ApproveHandler(employeeService: EmployeeService) def onCommand(entityId: String, state: ContractState.Pending, command: ContractCommand.Approve) : Task[ContractEvent.Approved] = for - user <- ZIO.succeed("") // TODO current user + user <- ZIO.succeed("") // TODO current user employee <- ZIO .fromEither(employeeService.fetchOne(user)) .mapError(new IllegalArgumentException(_)) @@ -116,7 +112,7 @@ class RejectHandler(employeeService: EmployeeService) def onCommand(entityId: String, state: ContractState.Pending, command: ContractCommand.Reject) : Task[ContractEvent.Rejected] = for - user <- ZIO.succeed("") // TODO current user + user <- ZIO.succeed("") // TODO current user employee <- ZIO .fromEither(employeeService.fetchOne(user)) .mapError(new IllegalArgumentException(_))