Skip to content

Commit

Permalink
fix(core): Handle correctly PillarsError in API (#86)
Browse files Browse the repository at this point in the history
  • Loading branch information
rlemaitre authored Mar 14, 2024
1 parent 2268520 commit 0e6ca91
Show file tree
Hide file tree
Showing 15 changed files with 228 additions and 48 deletions.
23 changes: 12 additions & 11 deletions modules/core/src/main/scala/pillars/ApiServer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,18 @@ trait ApiServer[F[_]]:
object ApiServer:
def apply[F[_]]: Run[F, ApiServer[F]] = summon[Pillars[F]].apiServer
def init[F[_]: Async](config: Config, observability: Observability[F], logger: Scribe[F]): ApiServer[F] =
(endpoints: List[HttpEndpoint[F]]) =>
Async[F].whenA(config.enabled):
for
_ <- logger.info(s"Starting API server on ${config.http.host}:${config.http.port}")
_ <- HttpServer
.build("api", config.http, observability, endpoints)
.onFinalizeCase:
case ExitCase.Errored(e) => logger.error(s"API server stopped with error: $e")
case _ => logger.info("API server stopped")
.useForever
yield ()
new ApiServer[F]:
override def start(endpoints: List[HttpEndpoint[F]]): F[Unit] =
Async[F].whenA(config.enabled):
for
_ <- logger.info(s"Starting API server on ${config.http.host}:${config.http.port}")
_ <- HttpServer
.build("api", config.http, observability, endpoints)
.onFinalizeCase:
case ExitCase.Errored(e) => logger.error(s"API server stopped with error: $e")
case _ => logger.info("API server stopped")
.useForever
yield ()
trait Error extends PillarsError:
override def status: StatusCode
final override def code: Code = Code("API")
Expand Down
20 changes: 19 additions & 1 deletion modules/core/src/main/scala/pillars/HttpServer.scala
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
package pillars

import cats.Applicative
import cats.effect.Async
import cats.effect.Resource
import cats.syntax.all.*
import com.comcast.ip4s.*
import io.circe.Codec
import io.circe.derivation.Configuration
import mouse.all.anySyntaxMouse
import org.http4s.HttpApp
import org.http4s.HttpVersion
import org.http4s.Response
import org.http4s.Status
import org.http4s.circe.CirceEntityCodec.circeEntityEncoder
import org.http4s.netty.server.NettyServerBuilder
import org.http4s.server.Server
import org.http4s.server.middleware.CORS
import org.http4s.server.middleware.ErrorHandling
import org.http4s.server.middleware.Logger
import pillars.Controller.HttpEndpoint
import pillars.codec.given
import sttp.tapir.*
import sttp.tapir.server.http4s.Http4sServerInterpreter
import sttp.tapir.server.http4s.Http4sServerOptions

Expand All @@ -26,7 +33,7 @@ object HttpServer:
): Resource[F, Server] =
val cors: HttpApp[F] => HttpApp[F] =
CORS.policy.withAllowMethodsAll.withAllowOriginAll.withAllowHeadersAll.httpApp[F]
val errorHandling: HttpApp[F] => HttpApp[F] = ErrorHandling.httpApp[F]
val errorHandling: HttpApp[F] => HttpApp[F] = ErrorHandling.Custom.recoverWith(_)(buildExceptionHandler())
val logging = Logger.httpApp[F](
logHeaders = false,
logBody = true,
Expand All @@ -53,6 +60,17 @@ object HttpServer:
.withoutBanner
.resource
end build

private def buildExceptionHandler[F[_]: Applicative](): PartialFunction[Throwable, F[Response[F]]] =
case e: PillarsError =>
Response(
Status.fromInt(e.status.code).getOrElse(Status.InternalServerError),
HttpVersion.`HTTP/1.1`
).withEntity(e.view).pure[F]
case e: Throwable =>
Response(Status.InternalServerError, HttpVersion.`HTTP/1.1`).withEntity(e.getMessage).pure[F]
end buildExceptionHandler

final case class Config(
host: Host,
port: Port,
Expand Down
11 changes: 7 additions & 4 deletions modules/core/src/main/scala/pillars/PillarsError.scala
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,15 @@ trait PillarsError extends Throwable, NoStackTrace:
def code: Code
def number: ErrorNumber
def message: Message
def details: Option[String] = None
def status: StatusCode = StatusCode.InternalServerError
override def getMessage: String = f"$code-$number%04d : $message"
def view[T]: Either[(StatusCode, PillarsError.View), T] =
def details: Option[String] = None
def status: StatusCode = StatusCode.InternalServerError
override def getMessage: String = f"$code-$number%04d : $message"

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

def view: PillarsError.View = PillarsError.View(f"$code-$number%04d", message, details)

end PillarsError

object PillarsError:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ create table if not exists country (

create table if not exists user_registry (
id serial primary key,
name varchar(255) unique not null,
first_name text,
last_name text,
email text unique not null,
age int,
country char(2) references country(iso)
);
Expand Down
25 changes: 25 additions & 0 deletions modules/example/src/main/rest/app.http
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
@host = http://localhost:9876

###
# @name home
GET {{ host }}/

###
# @name user list
GET {{ host }}/v0/user

###
# @name user create
POST {{ host }}/v0/user

{
"firstName": "John",
"lastName": "Doe",
"age": 25,
"country": "FR",
"email": "[email protected]"
}

###
# @name user get
GET {{ host }}/v0/user/1
46 changes: 46 additions & 0 deletions modules/example/src/main/scala/example/Endpoints.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package example

import cats.syntax.all.*
import example.codec.json.given
import pillars.PillarsError
import sttp.tapir.*
import sttp.tapir.codec.iron.given
import sttp.tapir.json.circe.jsonBody

object Endpoints:
val home: Endpoint[Unit, Unit, Unit, String, Any] = endpoint.get.out(stringBody)

private val base = endpoint.in("v0").errorOut(jsonBody[PillarsError.View])
private val userBase = base.in("user")

val createUser: Endpoint[Unit, UserView, PillarsError.View, UserView, Any] = userBase
.in(jsonBody[UserView])
.post
.out(jsonBody[UserView])
.name("create user")

val listUser: Endpoint[Unit, Unit, PillarsError.View, List[UserView], Any] = userBase
.get
.out(jsonBody[List[UserView]])
.name("list user")

val getUser: Endpoint[Unit, Email, PillarsError.View, UserView, Any] = userBase
.in(path[Email].name("e-mail"))
.get
.out(jsonBody[UserView])
.name("get user")

val deleteUser: Endpoint[Unit, Email, PillarsError.View, UserView, Any] = userBase
.in(path[Email].name("e-mail"))
.delete
.out(jsonBody[UserView])
.name("delete user")

val all = List(
home,
createUser,
listUser,
getUser,
deleteUser
)
end Endpoints
5 changes: 3 additions & 2 deletions modules/example/src/main/scala/example/app.scala
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@ object app extends pillars.EntryPoint: // // <1>
def app: pillars.App[IO] = new: // // <2>
def infos: AppInfo = BuildInfo.toAppInfo // // <3>

def endpoints = TodoController().endpoints.map(_.endpoint)
def endpoints = Endpoints.all

def run: Run[IO, IO[Unit]] = // // <4>
val controllers: List[Controller[IO]] = List(HomeController(), UserController())
for
_ <- Logger[IO].info(s"📚 Welcome to ${Config[IO].name}!")
_ <- DBMigration[IO].migrate("classpath:db-migrations") // // <5>
Expand All @@ -35,7 +36,7 @@ object app extends pillars.EntryPoint: // // <1>
size <- response.body.compile.count
_ <- Logger[IO].info(s"Body: $size bytes")
yield ()
_ <- ApiServer[IO].start(TodoController().endpoints)
_ <- ApiServer[IO].start(controllers.foldLeft(List.empty)(_ ++ _.endpoints))
yield ()
end for
end run
Expand Down
32 changes: 32 additions & 0 deletions modules/example/src/main/scala/example/codec.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package example

import io.circe.generic.semiauto.deriveCodec
import io.github.iltotore.iron.*
import io.github.iltotore.iron.circe.given
import sttp.tapir.Schema
import sttp.tapir.codec.iron.given
object codec:
object db:
import skunk.*
import skunk.codec.all.*

val countryCode: Codec[CountryCode] = varchar(2).eimap(CountryCode.either)(_.value)
val countryName: Codec[CountryName] = varchar.eimap(CountryName.either)(_.value)
val country: Codec[Country] =
(countryCode *: countryName *: text).imap(Country.apply)(c => (c.code, c.name, c.niceName))

val firstName: Codec[FirstName] = text.eimap(FirstName.either)(_.value)
val lastName: Codec[LastName] = text.eimap(LastName.either)(_.value)
val email: Codec[Email] = text.eimap(Email.either)(_.value)
val age: Codec[Age] = int4.eimap(Age.either)(_.value)
val user: Codec[User] =
(firstName *: lastName *: email *: age *: country).imap(User.apply)(u =>
(u.firstName, u.lastName, u.email, u.age, u.country)
)
end db

object json:
import io.circe.*
given Codec[UserView] = deriveCodec[UserView]
given Schema[UserView] = Schema.derived
end codec
29 changes: 29 additions & 0 deletions modules/example/src/main/scala/example/controllers.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package example

import cats.effect.IO
import cats.syntax.all.*
import pillars.Controller
import pillars.Controller.HttpEndpoint
import pillars.Pillars

final case class HomeController()(using Pillars[IO]) extends Controller[IO]:
def list: HttpEndpoint[IO] = Endpoints.home.serverLogicSuccess: _ =>
"👋 Hi".pure[IO]
val endpoints = List(list)
end HomeController

final case class UserController()(using Pillars[IO]) extends Controller[IO]:
def list: HttpEndpoint[IO] = Endpoints.listUser.serverLogic: _ =>
Left(errors.api.NotImplemented.view).pure[IO]

def create: HttpEndpoint[IO] = Endpoints.createUser.serverLogic: _ =>
Left(errors.api.NotImplemented.view).pure[IO]

def get: HttpEndpoint[IO] = Endpoints.getUser.serverLogic: _ =>
Left(errors.api.NotImplemented.view).pure[IO]

def delete: HttpEndpoint[IO] = Endpoints.deleteUser.serverLogic: _ =>
Left(errors.api.NotImplemented.view).pure[IO]

val endpoints = List(list, create, get, delete)
end UserController
12 changes: 0 additions & 12 deletions modules/example/src/main/scala/example/endpoints.scala

This file was deleted.

16 changes: 16 additions & 0 deletions modules/example/src/main/scala/example/errors.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package example

import io.github.iltotore.iron.*
import pillars.ApiServer
import pillars.PillarsError
import pillars.PillarsError.*
import sttp.model.StatusCode

object errors:
enum api(val number: PillarsError.ErrorNumber, override val status: StatusCode, val message: PillarsError.Message)
extends ApiServer.Error:
case NotImplemented extends api(ErrorNumber(1), StatusCode.NotImplemented, Message("Not implemented"))
case NotFound extends api(ErrorNumber(2), StatusCode.NotFound, Message("Not found"))
case AlreadyExists extends api(ErrorNumber(3), StatusCode.Conflict, Message("Already exists"))
end api
end errors
38 changes: 24 additions & 14 deletions modules/example/src/main/scala/example/model.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,35 @@ package example

import io.github.iltotore.iron.*
import io.github.iltotore.iron.constraint.all.*
import java.time.*

opaque type Username <: String = String :| MinLength[3] & MaxLength[20]
object Username extends RefinedTypeOps[String, MinLength[3] & MaxLength[20], Username]
type UsernameConstraint = (MinLength[3] & MaxLength[20]) DescribedAs "Must be between 3 and 20 characters"
opaque type Username <: String = String :| UsernameConstraint
object Username extends RefinedTypeOps[String, UsernameConstraint, Username]

opaque type Age <: Int = Int :| Positive & Less[150]
object Age extends RefinedTypeOps[Int, Positive & Less[150], Age]
type AgeConstraint = (Positive & Less[150]) DescribedAs "Must be a positive number less than 150"
opaque type Age <: Int = Int :| AgeConstraint
object Age extends RefinedTypeOps[Int, AgeConstraint, Age]

opaque type Title <: String = String :| Not[Blank]
object Title extends RefinedTypeOps[String, Not[Blank], Title]
type FirstNameConstraint = Not[Blank] DescribedAs "First name must not be blank"
opaque type FirstName <: String = String :| FirstNameConstraint
object FirstName extends RefinedTypeOps[String, FirstNameConstraint, FirstName]

opaque type FirstName <: String = String :| Not[Blank]
object FirstName extends RefinedTypeOps[String, Not[Blank], FirstName]
type LastNameConstraint = Not[Blank] DescribedAs "Last name must not be blank"
opaque type LastName <: String = String :| LastNameConstraint
object LastName extends RefinedTypeOps[String, LastNameConstraint, LastName]

opaque type LastName <: String = String :| Not[Blank]
object LastName extends RefinedTypeOps[String, Not[Blank], LastName]
type EmailConstraint = Match[".*@.*\\..*"] DescribedAs "Must be a valid e-mail"
opaque type Email <: String = String :| EmailConstraint
object Email extends RefinedTypeOps[String, EmailConstraint, Email]

case class Book(title: Title, authors: List[Author], year: Year, pages: Int)
type CountryNameConstraint = Not[Blank] DescribedAs "Country name must not be blank"
opaque type CountryName <: String = String :| CountryNameConstraint
object CountryName extends RefinedTypeOps[String, CountryNameConstraint, CountryName]

case class Author(firstName: FirstName, lastName: LastName)
type CountryCodeConstraint = (FixedLength[2] & LettersUpperCase) DescribedAs "Country name must not be blank"
opaque type CountryCode <: String = String :| CountryCodeConstraint
object CountryCode extends RefinedTypeOps[String, CountryCodeConstraint, CountryCode]

case class User(name: Username, age: Age)
case class Country(code: CountryCode, name: CountryName, niceName: String)

case class User(firstName: FirstName, lastName: LastName, email: Email, age: Age, country: Country)
9 changes: 9 additions & 0 deletions modules/example/src/main/scala/example/views.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package example

final case class UserView(
firstName: Option[FirstName],
lastName: Option[LastName],
email: Email,
age: Option[Age],
country: Option[CountryCode]
)
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,14 @@ final case class FlagController[F[_]: Functor](manager: FlagManager[F]) extends
.getFlag(name)
.map:
case Some(flag) => Right(flag)
case None => FlagError.FlagNotFound(name).view
case None => FlagError.FlagNotFound(name).httpResponse
private val modify =
FlagEndpoints.edit.serverLogic: (name, flag) =>
manager
.setStatus(name, flag.status)
.map:
case Some(flag) => Right(flag)
case None => FlagError.FlagNotFound(name).view
case None => FlagError.FlagNotFound(name).httpResponse

override def endpoints: List[HttpEndpoint[F]] = List(listAll, getOne, modify)
end FlagController
Expand Down
2 changes: 1 addition & 1 deletion project/Dependencies.scala
Original file line number Diff line number Diff line change
Expand Up @@ -117,5 +117,5 @@ object Dependencies {
) ++ tests
val httpClient: Seq[ModuleID] = http4sClient ++ http4s ++ tests
val core: Seq[ModuleID] =
effect ++ json ++ tapir ++ http4sServer ++ model ++ commandLine ++ logging ++ observability ++ tests
effect ++ json ++ tapir ++ http4s ++ http4sServer ++ model ++ commandLine ++ logging ++ observability ++ tests
}

0 comments on commit 0e6ca91

Please sign in to comment.