Skip to content

Commit e097b16

Browse files
committed
feat(core): improve error handling and type safety
- Add type alias HttpErrorResponse for (StatusCode, PillarsError.View) - Add extension method toEitherWithError for Option to Either conversion
1 parent c56f3b9 commit e097b16

20 files changed

Lines changed: 77 additions & 134 deletions

File tree

.g8/module/modules/$moduleFolder$/src/main/scala/pillars/$package$/$Prefix$Loader.scala

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ import io.github.iltotore.iron.*
1515
import io.github.iltotore.iron.circe.given
1616
import io.github.iltotore.iron.constraint.all.*
1717
import org.typelevel.otel4s.trace.Tracer
18-
import pillars.Config.Secret
18+
import pillars.Config
19+
import pillars.Config.*
1920
import pillars.Module
2021
import pillars.ModuleDef
2122
import pillars.Modules
@@ -75,10 +76,10 @@ case class $Prefix$Config(
7576
port: Port = port"5672",
7677
username: Option[$Prefix$User] = None,
7778
password: Option[Secret[$Prefix$Password]] = None
78-
)
79+
) extends Config
7980

8081
object $Prefix$Config:
81-
given Configuration = Configuration.default.withKebabCaseMemberNames.withKebabCaseConstructorNames.withDefaults
82+
given Configuration = defaultCirceConfig
8283
given Codec[$Prefix$Config] = Codec.AsObject.derivedConfigured
8384
end $Prefix$Config
8485

modules/core/src/main/scala/pillars/AdminServer.scala

Lines changed: 7 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,42 +11,27 @@ import com.comcast.ip4s.*
1111
import io.circe.Codec
1212
import io.circe.derivation.Configuration
1313
import pillars.AdminServer.Config
14-
import sttp.model.StatusCode
14+
import scribe.cats.io.*
1515
import sttp.tapir.*
1616

17-
final case class AdminServer(
18-
config: Config,
19-
infos: AppInfo,
20-
obs: Observability,
21-
controllers: List[Controller]
22-
):
17+
final case class AdminServer(config: Config, infos: AppInfo, obs: Observability, controllers: List[Controller]):
2318
def start(): IO[Unit] =
24-
val logger = scribe.cats.io
25-
import logger.*
26-
if config.enabled then
19+
IO.whenA(config.enabled):
2720
for
2821
_ <- info(s"Starting admin server on ${config.http.host}:${config.http.port}")
2922
_ <- HttpServer
30-
.build(
31-
"admin",
32-
config.http,
33-
config.openApi,
34-
infos,
35-
obs,
36-
controllers.flatten
37-
)
23+
.build("admin", config.http, config.openApi, infos, obs, controllers.flatten)
3824
.onFinalizeCase:
3925
case ExitCase.Errored(e) => error(s"Admin server stopped with error: $e")
4026
case _ => info("Admin server stopped")
4127
.useForever
4228
yield ()
43-
else IO.unit
44-
end if
29+
end for
4530
end start
4631
end AdminServer
4732

4833
object AdminServer:
49-
val baseEndpoint: Endpoint[Unit, Unit, (StatusCode, PillarsError.View), Unit, Any] =
34+
val baseEndpoint: Endpoint[Unit, Unit, HttpErrorResponse, Unit, Any] =
5035
endpoint.in("admin").errorOut(PillarsError.View.output)
5136

5237
final case class Config(
@@ -55,7 +40,7 @@ object AdminServer:
5540
openApi: HttpServer.Config.OpenAPI = HttpServer.Config.OpenAPI()
5641
) extends pillars.Config
5742

58-
given Configuration = Configuration.default.withKebabCaseMemberNames.withKebabCaseConstructorNames.withDefaults
43+
given Configuration = pillars.Config.defaultCirceConfig
5944
given Codec[Config] = Codec.AsObject.derivedConfigured
6045

6146
private val defaultHttp = HttpServer.Config(

modules/core/src/main/scala/pillars/ApiServer.scala

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -22,30 +22,22 @@ trait ApiServer:
2222
def start(endpoints: List[HttpEndpoint]): IO[Unit]
2323

2424
@targetName("startWithEndpoints")
25-
def start(endpoints: HttpEndpoint*): IO[Unit] =
26-
start(endpoints.toList)
25+
def start(endpoints: HttpEndpoint*): IO[Unit] = start(endpoints.toList)
2726

2827
@targetName("startWithControllers")
29-
def start(controllers: Controller*): IO[Unit] =
30-
start(controllers.toList.flatten)
28+
def start(controllers: Controller*): IO[Unit] = start(controllers.toList.flatten)
3129

3230
end ApiServer
3331

3432
def server(using p: Pillars): Run[ApiServer] = p.apiServer
3533

3634
object ApiServer:
37-
def init(
38-
config: Config,
39-
infos: AppInfo,
40-
observability: Observability,
41-
logger: Scribe[IO]
42-
): ApiServer =
35+
def init(config: Config, infos: AppInfo, observability: Observability, logger: Scribe[IO]): ApiServer =
4336
(endpoints: List[HttpEndpoint]) =>
4437
IO.whenA(config.enabled):
4538
for
4639
_ <- logger.info(s"Starting API server on ${config.http.host}:${config.http.port}")
47-
_ <- HttpServer
48-
.build("api", config.http, config.openApi, infos, observability, endpoints)
40+
_ <- HttpServer.build("api", config.http, config.openApi, infos, observability, endpoints)
4941
.onFinalizeCase:
5042
case ExitCase.Errored(e) => logger.error(s"API server stopped with error: $e")
5143
case _ => logger.info("API server stopped")
@@ -62,14 +54,10 @@ object ApiServer:
6254
openApi: HttpServer.Config.OpenAPI = HttpServer.Config.OpenAPI()
6355
) extends pillars.Config
6456

65-
given Configuration = Configuration.default.withKebabCaseMemberNames.withKebabCaseConstructorNames.withDefaults
57+
given Configuration = pillars.Config.defaultCirceConfig
6658
given Codec[Config] = Codec.AsObject.derivedConfigured
6759

68-
private val defaultHttp = HttpServer.Config(
69-
host = host"0.0.0.0",
70-
port = port"9876",
71-
logging = Logging.HttpConfig()
72-
)
60+
private val defaultHttp = HttpServer.Config(host = host"0.0.0.0", port = port"9876", logging = Logging.HttpConfig())
7361

7462
def noop: ApiServer = _ => IO.unit
7563
end ApiServer

modules/core/src/main/scala/pillars/Config.scala

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ def config(using p: Pillars): Config.PillarsConfig = p.config
3030
trait Config
3131

3232
object Config:
33+
val defaultCirceConfig: Configuration =
34+
Configuration.default.withSnakeCaseMemberNames.withSnakeCaseConstructorNames.withDefaults
3335
case class PillarsConfig(
3436
name: App.Name,
3537
log: Logging.Config = Logging.Config(),
@@ -39,7 +41,7 @@ object Config:
3941
) extends pillars.Config
4042

4143
object PillarsConfig:
42-
given Configuration = Configuration.default.withKebabCaseMemberNames.withKebabCaseConstructorNames.withDefaults
44+
given Configuration = defaultCirceConfig
4345
given Decoder[PillarsConfig] = Decoder.derivedConfigured
4446
given Encoder[PillarsConfig] = Encoder.AsObject.derivedConfigured
4547
end PillarsConfig
@@ -53,8 +55,7 @@ object Config:
5355
private def readConfig[T: Decoder](using Files[IO]): Resource[IO, Either[ParsingFailure, Json]] =
5456
Resource.eval(Files[IO].readUtf8(path)
5557
.map(regex.replaceAllIn(_, matcher))
56-
.map: input =>
57-
Parser.default.parse(input)
58+
.map(Parser.default.parse)
5859
.compile
5960
.onlyOrError)
6061

@@ -75,12 +76,10 @@ object Config:
7576
end Reader
7677

7778
final case class Redacted[T](value: T) extends AnyVal:
78-
override def toString: String =
79-
s"REDACTED"
79+
override def toString: String = s"REDACTED"
8080

8181
object Redacted:
8282
given [T: Decoder: Show]: Decoder[Redacted[T]] = summon[Decoder[T]].map(Redacted.apply)
83-
8483
given [T: Encoder: Show]: Encoder[Redacted[T]] = summon[Encoder[T]].contramap(_.value)
8584
end Redacted
8685

@@ -92,7 +91,6 @@ object Config:
9291

9392
object Secret:
9493
given [T: Decoder]: Decoder[Secret[T]] = summon[Decoder[T]].map(Secret.apply)
95-
9694
given [T: Encoder]: Encoder[Secret[T]] = summon[Encoder[T]].contramap(_.value)
9795
end Secret
9896

modules/core/src/main/scala/pillars/HttpServer.scala

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -108,31 +108,25 @@ object HttpServer:
108108
): IO[Option[ValuedEndpointOutput[?]]] =
109109
def handlePillarsError(e: PillarsError) =
110110
Some(ValuedEndpointOutput(statusCode.and(jsonBody[PillarsError.View]), (e.status, e.view)))
111-
tracer
112-
.currentSpanOrNoop
111+
tracer.currentSpanOrNoop
113112
.flatMap: span =>
114113
for
115114
_ <- span.recordException(ctx.e)
116115
_ <- span.addAttributes(Observability.Attributes.fromError(ctx.e))
117116
_ <- span.setStatus(StatusCode.Error, ctx.e.getMessage)
118117
yield ctx.e match
119-
case e: PillarsError =>
120-
handlePillarsError(e)
118+
case e: PillarsError => handlePillarsError(e)
121119
case StreamMaxLengthExceededException(maxBytes) =>
122120
handlePillarsError(PillarsError.PayloadTooLarge(maxBytes))
123121
case _ =>
124122
handlePillarsError(PillarsError.fromThrowable(ctx.e))
125123
end apply
126124

127-
final case class Config(
128-
host: Host,
129-
port: Port,
130-
logging: Logging.HttpConfig = Logging.HttpConfig()
131-
) extends pillars.Config
125+
final case class Config(host: Host, port: Port, logging: Logging.HttpConfig = Logging.HttpConfig())
126+
extends pillars.Config
132127

133128
object Config:
134-
given Configuration = Configuration.default.withKebabCaseMemberNames.withKebabCaseConstructorNames.withDefaults
135-
129+
given Configuration = pillars.Config.defaultCirceConfig
136130
given Codec[Config.OpenAPI] = Codec.AsObject.derivedConfigured
137131
given Codec[Config] = Codec.AsObject.derivedConfigured
138132

@@ -144,6 +138,5 @@ object HttpServer:
144138
useRelativePaths: Boolean = true,
145139
showExtensions: Boolean = false
146140
)
147-
148141
end Config
149142
end HttpServer

modules/core/src/main/scala/pillars/Logging.scala

Lines changed: 8 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,8 @@ object Logging:
3030
scribe.Logger.root
3131
.clearHandlers()
3232
.clearModifiers()
33-
.withHandler(
34-
formatter = config.format.formatter,
35-
minimumLevel = Some(config.level),
36-
writer = writer(config)
37-
).replace()
33+
.withHandler(config.format.formatter, writer(config), Some(config.level))
34+
.replace()
3835
).void
3936

4037
private def writer(config: Config): Writer =
@@ -73,17 +70,10 @@ object Logging:
7370

7471
given Encoder[Format] = Encoder.encodeString.contramap(_.toString.toLowerCase)
7572

76-
given Decoder[Format] = Decoder.decodeString.emap {
77-
case "json" => Right(Format.Json)
78-
case "simple" => Right(Format.Simple)
79-
case "colored" => Right(Format.Colored)
80-
case "classic" => Right(Format.Classic)
81-
case "compact" => Right(Format.Compact)
82-
case "enhanced" => Right(Format.Enhanced)
83-
case "advanced" => Right(Format.Advanced)
84-
case "strict" => Right(Format.Strict)
85-
case other => Left(s"Unknown output format: $other")
86-
}
73+
given Decoder[Format] = Decoder.decodeString.emap: s =>
74+
Format.values.find(_.toString.equalsIgnoreCase(s)) match
75+
case Some(format) => Right(format)
76+
case None => Left(s"Unknown output format: $s")
8777
end Format
8878

8979
enum Output:
@@ -130,13 +120,10 @@ object Logging:
130120
) extends pillars.Config
131121

132122
object Config:
133-
given Configuration = Configuration.default.withKebabCaseMemberNames.withKebabCaseConstructorNames.withDefaults
134-
123+
given Configuration = pillars.Config.defaultCirceConfig
135124
given Decoder[Level] = Decoder.decodeString.emap(s => Level.get(s).toRight(s"Invalid log level $s"))
136-
137125
given Encoder[Level] = Encoder.encodeString.contramap(_.name)
138-
139-
given Codec[Config] = Codec.AsObject.derivedConfigured
126+
given Codec[Config] = Codec.AsObject.derivedConfigured
140127
end Config
141128

142129
final case class HttpConfig(

modules/core/src/main/scala/pillars/Observability.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ object Observability:
9292
end Config
9393

9494
object Config:
95-
given Configuration = Configuration.default.withKebabCaseMemberNames.withKebabCaseConstructorNames.withDefaults
95+
given Configuration = pillars.Config.defaultCirceConfig
9696
given Codec[Config] = Codec.AsObject.derivedConfigured
9797

9898
final case class Metrics(

modules/core/src/main/scala/pillars/PillarsError.scala

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import sttp.tapir.Schema
1717
import sttp.tapir.json.circe.jsonBody
1818
import sttp.tapir.statusCode
1919

20+
type HttpErrorResponse = (StatusCode, PillarsError.View)
21+
2022
trait PillarsError extends Throwable, NoStackTrace:
2123
def code: Code
2224
def number: ErrorNumber
@@ -25,7 +27,7 @@ trait PillarsError extends Throwable, NoStackTrace:
2527
def status: StatusCode = StatusCode.InternalServerError
2628
override def getMessage: String = f"$code-$number%04d : $message"
2729

28-
def httpResponse[T]: Either[(StatusCode, PillarsError.View), T] =
30+
def httpResponse[T]: Either[HttpErrorResponse, T] =
2931
Left((status, PillarsError.View(f"$code-$number%04d", message, details)))
3032

3133
def view: PillarsError.View = PillarsError.View(f"$code-$number%04d", message, details)
@@ -39,7 +41,7 @@ object PillarsError:
3941
case _ => Unknown(throwable)
4042
case class View(code: String, message: String, details: Option[String]) derives Codec.AsObject, Schema
4143
object View:
42-
val output: EndpointOutput[(StatusCode, View)] = statusCode.and(jsonBody[View])
44+
val output: EndpointOutput[HttpErrorResponse] = statusCode.and(jsonBody[View])
4345

4446
private final case class Unknown(reason: Throwable) extends PillarsError:
4547
override def code: Code = Code("ERR")

modules/core/src/main/scala/pillars/app.scala

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,17 +41,14 @@ abstract class IOApp(override val modules: ModuleSupport*) extends App(modules*)
4141
object App:
4242
private type NameConstraint = Not[Blank]
4343
opaque type Name <: String = String :| NameConstraint
44-
4544
object Name extends RefinedTypeOps[String, NameConstraint, Name]
4645

4746
private type VersionConstraint = SemanticVersion
4847
opaque type Version <: String = String :| VersionConstraint
49-
5048
object Version extends RefinedTypeOps[String, VersionConstraint, Version]
5149

5250
private type DescriptionConstraint = Not[Blank]
5351
opaque type Description <: String = String :| DescriptionConstraint
54-
5552
object Description extends RefinedTypeOps[String, DescriptionConstraint, Description]
5653
end App
5754

modules/core/src/main/scala/pillars/codec.scala

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,21 +19,19 @@ import scala.concurrent.duration.FiniteDuration
1919
import scala.jdk.DurationConverters.*
2020

2121
object codec:
22+
given Configuration = pillars.Config.defaultCirceConfig
2223

2324
given Decoder[Path] = Decoder.decodeString.emap(t => Right(Path(t)))
24-
2525
given Encoder[Path] = Encoder.encodeString.contramap(_.toString)
2626

2727
given Decoder[Host] = Decoder.decodeString.emap(t => Host.fromString(t).toRight("Failed to parse Host"))
28-
2928
given Encoder[Host] = Encoder.encodeString.contramap(_.toString)
3029

3130
given Decoder[Port] = Decoder.decodeInt.emap(t => Port.fromInt(t).toRight("Failed to parse Port"))
32-
3331
given Encoder[Port] = Encoder.encodeInt.contramap(_.value)
3432

3533
given Codec[Uri] = Codec.from(
36-
Decoder.decodeString.emap(t => Uri.fromString(t).leftMap(f => f.details)),
34+
Decoder.decodeString.emap(t => Uri.fromString(t).leftMap(_.details)),
3735
Encoder.encodeString.contramap(_.toString)
3836
)
3937

@@ -60,5 +58,4 @@ object codec:
6058
case other if other.eq(Duration.Undefined) => "undefined".asJson
6159
case other => other.toString.asJson
6260

63-
given Configuration = Configuration.default.withKebabCaseMemberNames.withKebabCaseConstructorNames.withDefaults
6461
end codec

0 commit comments

Comments
 (0)