Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 32 additions & 16 deletions build.sbt
Original file line number Diff line number Diff line change
@@ -1,23 +1,31 @@

val Versions = new {
val Http4s = "0.23.14"
val Zio = "2.0.0"
val ZioCatsInterop = "3.3.0"
}


name := "guardrail-sample-http4s-zio"
ThisBuild / organization := "se.hardchee"

ThisBuild / scalaVersion := "2.13.6"
ThisBuild / scalaVersion := "3.1.3"

// Convenience for cross-compat testing
ThisBuild / crossScalaVersions := Seq("2.12.14", "2.13.6")
ThisBuild / crossScalaVersions := Seq("2.12.14", "2.13.6", "3.1.3")

val commonDependencies = Seq(
// Depend on http4s-managed cats and circe
"org.http4s" %% "http4s-ember-client" % "0.21.24",
"org.http4s" %% "http4s-ember-server" % "0.21.24",
"org.http4s" %% "http4s-circe" % "0.21.24",
"org.http4s" %% "http4s-dsl" % "0.21.24",
"org.http4s" %% "http4s-armeria-server" % "0.5.0",
"org.http4s" %% "http4s-armeria-client" % "0.5.0",
"org.http4s" %% "http4s-circe" % Versions.Http4s,
"org.http4s" %% "http4s-dsl" % Versions.Http4s,
"dev.zio" %% "zio-logging-slf4j" % Versions.Zio,

// ZIO and the interop library
"dev.zio" %% "zio" % "1.0.9",
"dev.zio" %% "zio-interop-cats" % "2.5.1.0",
"dev.zio" %% "zio-test" % "1.0.9" % "test",
"dev.zio" %% "zio-test-sbt" % "1.0.9" % "test",
"dev.zio" %% "zio" % Versions.Zio,
"dev.zio" %% "zio-interop-cats" % Versions.ZioCatsInterop,
"dev.zio" %% "zio-test" % Versions.Zio % "test",
"dev.zio" %% "zio-test-sbt" % Versions.Zio % "test",
)

val commonSettings = Seq(
Expand All @@ -30,24 +38,32 @@ val commonSettings = Seq(
run / fork := true,

// Better syntax for dealing with partially-applied types
addCompilerPlugin("org.typelevel" %% "kind-projector" % "0.13.0" cross CrossVersion.full),
libraryDependencies ++= {
scalaVersion.value match {
case v if v.startsWith("3") =>
Nil
case _ =>
List(
compilerPlugin("org.typelevel" %% "kind-projector" % "0.13.0" cross CrossVersion.full),
compilerPlugin("com.olegpy" %% "better-monadic-for" % "0.3.1")
)
}
}

// Better semantics for for comprehensions
addCompilerPlugin("com.olegpy" %% "better-monadic-for" % "0.3.1"),
)

lazy val exampleServer = (project in file("example-server"))
.settings(commonSettings)
.settings(
Compile / guardrailTasks += ScalaServer(file("server.yaml"), pkg="example.server", framework="http4s"),
Compile / guardrailTasks += ScalaServer(file("server.yaml"), pkg = "example.server", framework = "http4s"),
libraryDependencies ++= commonDependencies
)
.dependsOn(exampleClient % "test")

lazy val exampleClient = (project in file("example-client"))
.settings(commonSettings)
.settings(
Compile / guardrailTasks += ScalaClient(file("server.yaml"), pkg="example.client", framework="http4s"),
Compile / guardrailTasks += ScalaClient(file("server.yaml"), pkg = "example.client", framework = "http4s"),
libraryDependencies ++= commonDependencies
)

Expand Down
27 changes: 27 additions & 0 deletions example-server/src/main/scala-2/example/AppRoutes.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package example

import example.server.store.{StoreHandler, StoreResource}
import org.http4s.HttpApp
import zio.{Task, ZIO, ZLayer}
import zio.interop.catz._

trait AppRoutes {
def handler: HttpApp[Task]
}

final case class LiveAppRoutes(storeHandler: StoreHandler[Task]) extends AppRoutes {

override def handler: HttpApp[Task] = {
val routes = (new StoreResource[Task]).routes(storeHandler)
routes.orNotFound
}
}

object AppRoutes {

val live: ZLayer[StoreHandler[Task], Nothing, AppRoutes] = ZLayer {
for {networkRoutes <- ZIO.service[StoreHandler[Task]]} yield LiveAppRoutes(networkRoutes)
}

}

97 changes: 97 additions & 0 deletions example-server/src/main/scala-2/example/Controller.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package example

import example.repository.{GetInventoryError, PlaceOrderError, RepositoryService, UnknownOrder}
import example.server.definitions.Order
import example.server.store.StoreHandler
import example.server.store.StoreResource.{DeleteOrderResponse, GetInventoryResponse, GetOrderByIdResponse, PlaceOrderResponse}
import zio._

sealed trait GetOrderByIdDownstreamErrors

object GetOrderByIdDownstreamErrors {
case class GOBIRepoError(error: UnknownOrder) extends GetOrderByIdDownstreamErrors
}

case class LiveStoreController(service: RepositoryService) extends StoreHandler[Task] {

/**
* getInventory
*
* Just grab from the repository
*
* Since we do not have multiple conflicting layers, we can just catchAll at the end to map errors
*/
def getInventory(respond: GetInventoryResponse.type)(): Task[GetInventoryResponse] = {
val action: ZIO[Any, GetInventoryError, GetInventoryResponse.Ok] = for {
inventory <- service.getInventory
} yield respond.Ok(inventory)

action.catchAll {
case GetInventoryError.StockroomUnavailable => ZIO.succeed(respond.InternalServerError("Stockroom unavailable, please try again later"))
}
}

/**
* placeOrder
*
* Grabbing optional fields from an optional body, so we mapError explicitly on every line to give a different example of error handling
* repository.placeOrder also uses mapError, but translates from the repository error type into our error response.
*/
def placeOrder(respond: PlaceOrderResponse.type)(body: Option[Order]): Task[PlaceOrderResponse] = {
val action = for {
order <- ZIO.fromOption(body).orElseFail(respond.MethodNotAllowed)
id <- ZIO.fromOption(order.id).orElseFail(respond.MethodNotAllowed)
petId <- ZIO.fromOption(order.petId).orElseFail(respond.MethodNotAllowed)
quantity <- ZIO.fromOption(order.quantity).orElseFail(respond.MethodNotAllowed)
res <- service.placeOrder(id, petId, quantity).mapError {
_ => respond.MethodNotAllowed
} // TODO: 405 isn't really applicable here
} yield respond.Ok(res)

action.merge // Second strategy of error handling, mapError to PlaceOrderResponses, then merge them all together
}


/**
* getOrderById
*
* If we had a whole bunch of conflicting error types from various layers, it may be useful to define a bespoke
* error tree to keep the function body terse, without losing any specificity or totality in catchAll
*/
def getOrderById(respond: GetOrderByIdResponse.type)(orderId: Long): Task[GetOrderByIdResponse] = {
val action = for {
order <- service.getOrder(orderId).mapError(GetOrderByIdDownstreamErrors.GOBIRepoError.apply)
} yield respond.Ok(order)

action.catchAll { // Third strategy of error handling, mapping to a custom ADT to accept disparate downstream error types (pseudo union types/coproduct)
case GetOrderByIdDownstreamErrors.GOBIRepoError(repository.UnknownOrder(id)) => ZIO.succeed(respond.NotFound)
}
}


/**
* deleteOrder
*
* The underlying repository function call can fail with different errors, so mapError
* those explicitly and use the .merge technique from placeOrder
*/
def deleteOrder(respond: DeleteOrderResponse.type)(orderId: Long): Task[DeleteOrderResponse] = {
val action = for {
_ <- service.deleteOrder(orderId).mapError {
case repository.UnknownOrder(id) => respond.NotFound
case repository.AlreadyDeleted(id) => respond.BadRequest
}
} yield respond.Accepted

action.merge
}

}

object StoreController {
val live: ZLayer[RepositoryService, Nothing, StoreHandler[Task]] = ZLayer {
for {
service <- ZIO.service[RepositoryService]
} yield LiveStoreController(service)
}
}
37 changes: 37 additions & 0 deletions example-server/src/main/scala-2/example/Launch.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package example

import example.httpServer.Http4sServerLauncher
import example.repository.RepositoryService
import org.http4s.server.Server
import zio.logging.backend.SLF4J
import zio.{&, ExitCode, LogLevel, Runtime, Scope, ZIO, ZIOAppArgs, ZLayer}
object Launch extends zio.ZIOAppDefault {

val logger = Runtime.removeDefaultLoggers >>> SLF4J.slf4j(LogLevel.Debug)

case class ApplicationConfig(inMemory: Boolean, port: Int)

def runWithConfig(config: ApplicationConfig): ZIO[Any with Scope, Throwable, Unit] = {

val action: ZIO[Server, Nothing, Unit] = for {
appRoutes <- ZIO.service[Server]
} yield ()

action.provideSome[Scope](
ZLayer.succeed(config),
Http4sServerLauncher.live,
AppRoutes.live,
StoreController.live,
RepositoryService.live
)
}

override def run: ZIO[Any & ZIOAppArgs & Scope, Any, Any] =
ZIO.attempt(ApplicationConfig(inMemory = true, port = 8080)).orDie.flatMap { config =>
ZIO.scoped {
(runWithConfig(config) *> ZIO.debug("Application started correctly"))
.catchAllCause(cause => ZIO.logError("Application failed to start" + cause.squash) as ExitCode.failure)
}
}.provide(logger) *> ZIO.never

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package example.httpServer

import cats.effect.Resource
import example.AppRoutes
import example.Launch.ApplicationConfig
import org.http4s.HttpApp
import org.http4s.armeria.server.ArmeriaServerBuilder
import org.http4s.server.{Server, defaults}
import zio._
import zio.interop.catz._
import zio.interop.catz.implicits._

object Http4sServerLauncher {
def apply(routes: HttpApp[Task], atPort: Option[Int]): ZIO[Any & Scope, Throwable, Server] =
ArmeriaServerBuilder[Task]
.bindHttp(atPort.fold(0)(identity))
.withHttpApp("/", routes)
.resource.toScopedZIO

val live: ZLayer[AppRoutes & ApplicationConfig & Scope, Throwable, Server] = ZLayer {
for {
config <- ZIO.service[ApplicationConfig]
routes <- ZIO.service[AppRoutes]
server <- apply(routes.handler, Option(config.port))
} yield server
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package example.repository

import example.server.definitions.Order
import zio.{IO, Ref, UIO, ZIO, ZLayer}

sealed trait GetInventoryError

object GetInventoryError {
case object StockroomUnavailable extends GetInventoryError
}

sealed trait PlaceOrderError

object PlaceOrderError {
case class InsufficientQuantity(id: Long)
}

sealed trait DeleteOrderError

case class UnknownOrder(id: Long) extends DeleteOrderError

case class AlreadyDeleted(id: Long) extends DeleteOrderError


trait RepositoryService {
def getInventory: IO[GetInventoryError, Map[String, Int]]

def placeOrder(id: Long, petId: Long, quantity: Int): IO[PlaceOrderError, Order]

def getOrder(id: Long): IO[UnknownOrder, Order]

def deleteOrder(id: Long): IO[DeleteOrderError, Unit]
}


/** Simple in-memory implementation */
case class InMemoryRepositoryService(inventory: Ref[Map[String, Int]], orders: Ref[Map[Long, Order]]) extends RepositoryService {

override def getInventory: IO[GetInventoryError, Map[String, Int]] = inventory.get

override def placeOrder(id: Long, petId: Long, quantity: Int): IO[PlaceOrderError, Order] = orders.modify { all =>
val order = Order(id = Some(id), petId = Some(petId), quantity = Some(quantity), status = Some(Order.Status.Placed))
(order, all + (id -> order))
}

override def getOrder(id: Long): IO[UnknownOrder, Order] = for {
currentOrders <- orders.get
order <- ZIO.fromOption(currentOrders.get(id)).orElseFail(UnknownOrder(id))
} yield order

override def deleteOrder(id: Long): IO[DeleteOrderError, Unit] = for {
order <- orders.modify(all => (all.get(id), all - id))
foundOrder <- ZIO.fromOption(order).orElseFail(UnknownOrder(id))
} yield ()

}

object RepositoryService {

private val initialInventory = Map("Kibble" -> 10, "Treats" -> 3)

private val initialOrders = Map(
123L -> Order(id = Some(123L), petId = Some(5L), quantity = Some(3), status = Some(Order.Status.Placed))
)

val live: ZLayer[Any, Nothing, RepositoryService] = ZLayer {
for {
inventory <- zio.Ref.make[Map[String, Int]](initialInventory)
orders <- zio.Ref.make[Map[Long, Order]](initialOrders)
} yield InMemoryRepositoryService(inventory, orders)
}
}
28 changes: 28 additions & 0 deletions example-server/src/main/scala-3/example/AppRoutes.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package example

import example.server.store.{StoreHandler, StoreResource}
import org.http4s.HttpApp
import zio.{Task, ZIO, ZLayer}
import zio.interop.catz.*
import scala.util.chaining.*

trait AppRoutes {
def handler: HttpApp[Task]
}

final case class LiveAppRoutes(storeHandler: StoreHandler[Task]) extends AppRoutes {

override def handler: HttpApp[Task] =
val routes = (new StoreResource[Task]).routes(storeHandler)
routes.orNotFound

}

object AppRoutes {

val live: ZLayer[StoreHandler[Task], Nothing, AppRoutes] = ZLayer {
for networkRoutes <- ZIO.service[StoreHandler[Task]] yield LiveAppRoutes(networkRoutes)
}

}

Loading