diff --git a/build.sbt b/build.sbt index 32ea8dd..522c4be 100644 --- a/build.sbt +++ b/build.sbt @@ -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( @@ -30,16 +38,24 @@ 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") @@ -47,7 +63,7 @@ lazy val exampleServer = (project in file("example-server")) 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 ) diff --git a/example-server/src/main/scala-2/example/AppRoutes.scala b/example-server/src/main/scala-2/example/AppRoutes.scala new file mode 100644 index 0000000..020b378 --- /dev/null +++ b/example-server/src/main/scala-2/example/AppRoutes.scala @@ -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) + } + +} + diff --git a/example-server/src/main/scala-2/example/Controller.scala b/example-server/src/main/scala-2/example/Controller.scala new file mode 100644 index 0000000..ea94239 --- /dev/null +++ b/example-server/src/main/scala-2/example/Controller.scala @@ -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) + } +} diff --git a/example-server/src/main/scala-2/example/Launch.scala b/example-server/src/main/scala-2/example/Launch.scala new file mode 100644 index 0000000..d868f7d --- /dev/null +++ b/example-server/src/main/scala-2/example/Launch.scala @@ -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 + +} diff --git a/example-server/src/main/scala-2/example/httpServer/Http4sServerLauncher.scala b/example-server/src/main/scala-2/example/httpServer/Http4sServerLauncher.scala new file mode 100644 index 0000000..a79e345 --- /dev/null +++ b/example-server/src/main/scala-2/example/httpServer/Http4sServerLauncher.scala @@ -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 + } + +} \ No newline at end of file diff --git a/example-server/src/main/scala-2/example/repository/RepositoryService.scala b/example-server/src/main/scala-2/example/repository/RepositoryService.scala new file mode 100644 index 0000000..c8d10c4 --- /dev/null +++ b/example-server/src/main/scala-2/example/repository/RepositoryService.scala @@ -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) + } +} diff --git a/example-server/src/main/scala-3/example/AppRoutes.scala b/example-server/src/main/scala-3/example/AppRoutes.scala new file mode 100644 index 0000000..de74a82 --- /dev/null +++ b/example-server/src/main/scala-3/example/AppRoutes.scala @@ -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) + } + +} + diff --git a/example-server/src/main/scala-3/example/Controller.scala b/example-server/src/main/scala-3/example/Controller.scala new file mode 100644 index 0000000..6a98dea --- /dev/null +++ b/example-server/src/main/scala-3/example/Controller.scala @@ -0,0 +1,92 @@ +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 { + case PlaceOrderError.InsufficientQuantity(id) => 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) + } +} diff --git a/example-server/src/main/scala-3/example/Launch.scala b/example-server/src/main/scala-3/example/Launch.scala new file mode 100644 index 0000000..7095090 --- /dev/null +++ b/example-server/src/main/scala-3/example/Launch.scala @@ -0,0 +1,35 @@ +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 + +} diff --git a/example-server/src/main/scala-3/example/httpServer/Http4sServerLauncher.scala b/example-server/src/main/scala-3/example/httpServer/Http4sServerLauncher.scala new file mode 100644 index 0000000..775f39b --- /dev/null +++ b/example-server/src/main/scala-3/example/httpServer/Http4sServerLauncher.scala @@ -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 with ApplicationConfig with Scope, Throwable, Server] = ZLayer { + for + config <- ZIO.service[ApplicationConfig] + routes <- ZIO.service[AppRoutes] + server <- apply(routes.handler, Option(config.port)) + yield server + } + +} \ No newline at end of file diff --git a/example-server/src/main/scala-3/example/repository/RepositoryService.scala b/example-server/src/main/scala-3/example/repository/RepositoryService.scala new file mode 100644 index 0000000..dcc2503 --- /dev/null +++ b/example-server/src/main/scala-3/example/repository/RepositoryService.scala @@ -0,0 +1,67 @@ +package example.repository + +import example.server.definitions.Order +import zio.{IO, Ref, UIO, ZIO, ZLayer} + +enum GetInventoryError: + case StockroomUnavailable + +enum PlaceOrderError: + case InsufficientQuantity(id: Long) + +case class UnknownOrder(id: Long) + +case class AlreadyDeleted(id: Long) + + +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[AlreadyDeleted | UnknownOrder, 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[AlreadyDeleted | UnknownOrder, 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) + } +} diff --git a/example-server/src/main/scala/example/Controller.scala b/example-server/src/main/scala/example/Controller.scala deleted file mode 100644 index 5bd5fb0..0000000 --- a/example-server/src/main/scala/example/Controller.scala +++ /dev/null @@ -1,123 +0,0 @@ -package example - -import zio._ - -sealed trait GetOrderByIdDownstreamErrors -final case class GOBIRepoError(error: repository.GetOrderError) extends GetOrderByIdDownstreamErrors - -object Controller { - - /** - * An effect which, when executed, gives a StoreResource (capable of transforming a StoreHandler into something bindable) - */ - val makeStoreResource: RIO[repository.Repository, server.store.StoreResource[RIO[repository.Repository, *]]] = { - import zio.interop.catz._ - ZIO.runtime[repository.Repository].map { implicit r: Runtime[repository.Repository] => - new server.store.StoreResource[RIO[repository.Repository, *]] - } - } - - /** - * Our HTTP server implementation, utilizing the Repository Layer - */ - val handler: server.store.StoreHandler[RIO[repository.Repository, *]] = - new server.store.StoreHandler[RIO[repository.Repository, *]] { - import example.server.store._ - - /** - * 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)(): RIO[repository.Repository,GetInventoryResponse] = ( - for { - inventory <- repository.getInventory - } yield respond.Ok(inventory) - ).catchAll { - case repository.StockroomUnavailable => UIO(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[example.server.definitions.Order]): RIO[repository.Repository,PlaceOrderResponse] = ( - 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 <- repository.placeOrder(id, petId, quantity).mapError({ case repository.InsufficientQuantity(id) => respond.MethodNotAllowed }) // TODO: 405 isn't really applicable here - } yield respond.Ok(res) - ).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): RIO[repository.Repository,GetOrderByIdResponse] = ( - for { - order <- repository.getOrder(orderId).mapError(GOBIRepoError) - } yield respond.Ok(order) - ).catchAll { // Third strategy of error handling, mapping to a custom ADT to accept disparate downstream error types (pseudo union types/coproduct) - case GOBIRepoError(repository.UnknownOrder(id)) => UIO(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): RIO[repository.Repository,DeleteOrderResponse] = ( - for { - () <- repository.deleteOrder(orderId).mapError { - case repository.UnknownOrder(id) => respond.NotFound - case repository.AlreadyDeleted(id) => respond.BadRequest - } - } yield respond.Accepted - ).merge - } - - import example.server.definitions.Order - val initialInventory = Map( - "Kibble" -> 10, - "Treats" -> 3 - ) - val inventoryLayer = Ref.make(initialInventory).toManaged_.toLayer - - val initialOrders = Map( - 123L -> Order(id = Some(123L), petId = Some(5L), quantity = Some(3), status = Some(Order.Status.Placed)) - ) - val ordersLayer = Ref.make(initialOrders).toManaged_.toLayer - - val combineRoutes = { - import zio.interop.catz._ - - import cats.syntax.all._ - import org.http4s.implicits._ - - for { - storeResource <- makeStoreResource - } yield storeResource.routes(handler).orNotFound - } - - val inMemoryLayer = (inventoryLayer ++ ordersLayer) >>> repository.Repository.inMemory - - val prog = - for { - combinedRoutes <- combineRoutes - binding <- httpServer.bindServer(combinedRoutes) - res <- binding.use(_ => ZIO.never) - } yield res - - val inMemoryProg = - prog - .provideSomeLayer[ZEnv with httpServer.HttpServer](inMemoryLayer) -} diff --git a/example-server/src/main/scala/example/Launch.scala b/example-server/src/main/scala/example/Launch.scala deleted file mode 100644 index ef21038..0000000 --- a/example-server/src/main/scala/example/Launch.scala +++ /dev/null @@ -1,11 +0,0 @@ -package example - -import zio.{ App, ZEnv } - -object Launch extends App { - def run(args: List[String]) = - Controller - .inMemoryProg - .exitCode - .provideSomeLayer[ZEnv](httpServer.HttpServer.live) -} diff --git a/example-server/src/main/scala/example/httpServer/package.scala b/example-server/src/main/scala/example/httpServer/package.scala deleted file mode 100644 index 591140e..0000000 --- a/example-server/src/main/scala/example/httpServer/package.scala +++ /dev/null @@ -1,46 +0,0 @@ -package example - -import zio._ - -package object httpServer { - type HttpServer = Has[HttpServer.Service] - - def bindServer[R](httpApp: org.http4s.Http[RIO[R, *], RIO[R, *]]) = ZIO.access[HttpServer with R](_.get.bindServer(httpApp)) -} - -package httpServer { - object HttpServer { - trait Service { - def bindServer[R](httpApp: org.http4s.Http[RIO[R, *], RIO[R, *]]): ZManaged[R, Throwable, org.http4s.server.Server[RIO[R, *]]] - } - - val live: ULayer[HttpServer] = ZLayer.succeed(new Service { - /** - * Breaking out bindServer to keep noise fairly self-contained. This could consume - * from some `Config` layer in order to access its port and host info. - */ - def bindServer[R](httpApp: org.http4s.Http[RIO[R, *], RIO[R, *]]): ZManaged[R, Throwable, org.http4s.server.Server[RIO[R, *]]] = { - import zio.interop.catz._ - import zio.interop.catz.implicits._ - - import cats.effect._ - import cats.syntax.all._ - import org.http4s._ - import org.http4s.dsl.io._ - import org.http4s.implicits._ - import org.http4s.ember.server.EmberServerBuilder - - implicit val timer: Timer[RIO[R, *]] = ioTimer[R, Throwable] - - ZIO.runtime - .toManaged_ - .flatMap { implicit r: Runtime[R] => - EmberServerBuilder.default[RIO[R, *]] - .withHttpApp(httpApp) - .build - .toManagedZIO - } - } - }) - } -} diff --git a/example-server/src/main/scala/example/repository/package.scala b/example-server/src/main/scala/example/repository/package.scala deleted file mode 100644 index 2c21e9f..0000000 --- a/example-server/src/main/scala/example/repository/package.scala +++ /dev/null @@ -1,72 +0,0 @@ -package example - - -import zio._ - -package object repository { - type Repository = Has[Repository.Service] - - /** - * RepositoryError - * - * Represent a kind of tree where we have the choice to stay within a single functions' - * error domain, or let variance widen all the way to the top. - */ - sealed trait RepositoryError - - sealed trait GetInventoryError extends RepositoryError - case object StockroomUnavailable extends GetInventoryError - - sealed trait PlaceOrderError extends RepositoryError - final case class InsufficientQuantity(id: Long) extends PlaceOrderError - - sealed trait GetOrderError extends RepositoryError - sealed trait DeleteOrderError extends RepositoryError - - final case class UnknownOrder(id: Long) extends GetOrderError with DeleteOrderError - final case class AlreadyDeleted(id: Long) extends DeleteOrderError - - /** - * Accessor proxies - */ - def getInventory = ZIO.accessM[Repository](_.get.getInventory) - def placeOrder(id: Long, petId: Long, quantity: Int) = ZIO.accessM[Repository](_.get.placeOrder(id, petId, quantity)) - def getOrder(id: Long) = ZIO.accessM[Repository](_.get.getOrder(id)) - def deleteOrder(id: Long) = ZIO.accessM[Repository](_.get.deleteOrder(id)) -} - -package repository { - import example.server.definitions.Order - object Repository { - trait Service { - def getInventory: IO[GetInventoryError, Map[String, Int]] - def placeOrder(id: Long, petId: Long, quantity: Int): IO[PlaceOrderError, Order] - def getOrder(id: Long): IO[GetOrderError, Order] - def deleteOrder(id: Long): IO[DeleteOrderError, Unit] - } - - /** - * Simple in-memory implementation - */ - val inMemory = ZLayer.fromServices[Ref[Map[String, Int]], Ref[Map[Long, Order]], Service]((inventory, orders) => - new Service { - def getInventory = inventory.get - def placeOrder(id: Long, petId: Long, quantity: Int) = - orders.modify { all => - val order = Order(id = Some(id), petId = Some(petId), quantity = Some(quantity), status = Some(Order.Status.Placed)) - (order, all + (id -> order)) - } - - def getOrder(id: Long) = for { - currentOrders <- orders.get - order <- ZIO.fromOption(currentOrders.get(id)).orElseFail(UnknownOrder(id)) - } yield order - def deleteOrder(id: Long): zio.IO[DeleteOrderError,Unit] = - for { - order <- orders.modify(all => (all.get(id), all - id)) - foundOrder <- ZIO.fromOption(order).orElseFail(UnknownOrder(id)) - } yield () - } - ) - } -} diff --git a/example-server/src/test/scala-2/example/tests/RoundTripSpec.scala b/example-server/src/test/scala-2/example/tests/RoundTripSpec.scala new file mode 100644 index 0000000..0741bd4 --- /dev/null +++ b/example-server/src/test/scala-2/example/tests/RoundTripSpec.scala @@ -0,0 +1,118 @@ +package example.tests + +import example.Launch.ApplicationConfig +import example.{AppRoutes, StoreController} +import zio._ +import zio.interop.catz._ +import java.io.IOException +import example.client.store.StoreClient +import example.client.store.GetOrderByIdResponse +import example.client.definitions.Order +import example.httpServer.Http4sServerLauncher +import example.repository.RepositoryService +import example.server.definitions.{Order => ServerOrder} +import org.http4s.armeria.client.ArmeriaClient +import scala.concurrent.duration._ +import org.http4s.client.Client +import org.http4s.server.Server +import zio.test.Assertion.equalTo +import zio.test._ +import java.net.URI + +object RoundTripSpec extends ZIOSpecDefault { + + import org.http4s.armeria.client.ArmeriaClientBuilder + + val client: Client[Task] = ArmeriaClient.apply() + + override def spec: Spec[TestEnvironment with Scope, Any] = suite("RoundTripSpec")( + /** + * This test hits the StoreHandler directly, bypassing all of the routing infrastructure in http4s + * This is useful for testing logic without the overhead of HTTP encoding and decoding, + * possibly in a situation where your tests are significantly slowed down by the round-trip through http4s. + */ + test("Test controller functions without the client") { + + val action = (for { + routes <- ZIO.service[AppRoutes] + server <- Http4sServerLauncher.apply(routes.handler, None) + res <- StoreClient.httpClient(client, s"http://localhost:${server.address.getPort}").getOrderById(123L) + } yield res).provide( + AppRoutes.live, + StoreController.live, + RepositoryService.live, + zio.Scope.default + ) + + for { + res <- action + } yield assert(res)(equalTo(GetOrderByIdResponse.Ok(Order(id = Some(123), petId = Some(5), quantity = Some(3), status = Some(Order.Status.Placed))))) + }, + + /** + * Build a simple client, then hit the endpoint. + * This is just a sanity check to ensure our tests are wired up correctly against the inMemoryLayer + */ + test("getOrderById can find values in the static, in-memory repository") { + val action = (for { + routes <- ZIO.service[AppRoutes] + server <- Http4sServerLauncher.apply(routes.handler, None) + res <- StoreClient.httpClient(client, s"http://localhost:${server.address.getPort}").getOrderById(123L) + } yield res).provide( + AppRoutes.live, + StoreController.live, + RepositoryService.live, + zio.Scope.default + ) + + for { + res <- action + } yield assert(res)(equalTo(GetOrderByIdResponse.Ok(Order(id = Some(123), petId = Some(5), quantity = Some(3), status = Some(Order.Status.Placed))))) + }, + + /** + * A negative test, to ensure that we get a NotFound value back, instead of an exception. + */ + test("getOrderById correctly returns NotFound for incorrect ids") { + val action = (for { + routes <- ZIO.service[AppRoutes] + server <- Http4sServerLauncher.apply(routes.handler, None) + res <- StoreClient.httpClient(client, s"http://localhost:${server.address.getPort}").getOrderById(404L) + } yield res).provide( + AppRoutes.live, + StoreController.live, + RepositoryService.live, + zio.Scope.default + ) + + for {res <- action} yield assert(res)(equalTo(GetOrderByIdResponse.NotFound)) + }, + + /** + * Test mutating the in-memory Repository. + * This is a multi-phase test, + * - verifying the negative case, + * - mutating the store, + * - then verifying the positive case. + */ + test("placeOrder followed by getOrder") { + val myOrder = Order(id = Some(5), petId = Some(6), quantity = Some(1), status = Some(Order.Status.Placed)) + (for { + routes <- ZIO.service[AppRoutes] + server <- Http4sServerLauncher.apply(routes.handler, None) + first <- StoreClient.httpClient(client, s"http://localhost:${server.address.getPort}").getOrderById(5) + placedResponse <- StoreClient.httpClient(client, s"http://localhost:${server.address.getPort}").placeOrder(Some(myOrder)) + second <- StoreClient.httpClient(client, s"http://localhost:${server.address.getPort}").getOrderById(5) + } yield { + assert(first)(equalTo(GetOrderByIdResponse.NotFound)) && + assert(second)(equalTo(GetOrderByIdResponse.Ok(myOrder))) + }).provide( + AppRoutes.live, + StoreController.live, + RepositoryService.live, + zio.Scope.default + ) + + } + ) +} diff --git a/example-server/src/test/scala-3/example/tests/RoundTripSpec.scala b/example-server/src/test/scala-3/example/tests/RoundTripSpec.scala new file mode 100644 index 0000000..7c612bc --- /dev/null +++ b/example-server/src/test/scala-3/example/tests/RoundTripSpec.scala @@ -0,0 +1,121 @@ +package example.tests + +import example.Launch.ApplicationConfig +import example.{AppRoutes, StoreController} +import zio.* +import zio.interop.catz.* + +import java.io.IOException +import example.client.store.StoreClient +import example.client.store.GetOrderByIdResponse +import example.client.definitions.Order +import example.httpServer.Http4sServerLauncher +import example.repository.RepositoryService +import example.server.definitions.Order as ServerOrder +import org.http4s.armeria.client.ArmeriaClient + +import scala.concurrent.duration.* +import org.http4s.client.Client +import org.http4s.server.Server +import zio.test.Assertion.equalTo +import zio.test.* + +import java.net.URI + +object RoundTripSpec extends ZIOSpecDefault { + + import org.http4s.armeria.client.ArmeriaClientBuilder + + val client: Client[Task] = ArmeriaClient.apply() + + override def spec: Spec[TestEnvironment with Scope, Any] = suite("RoundTripSpec")( + /** + * This test hits the StoreHandler directly, bypassing all of the routing infrastructure in http4s + * This is useful for testing logic without the overhead of HTTP encoding and decoding, + * possibly in a situation where your tests are significantly slowed down by the round-trip through http4s. + */ + test("Test controller functions without the client") { + + val action = (for { + routes <- ZIO.service[AppRoutes] + server <- Http4sServerLauncher.apply(routes.handler, None) + res <- StoreClient.httpClient(client, s"http://localhost:${server.address.getPort}").getOrderById(123L) + } yield res).provide( + AppRoutes.live, + StoreController.live, + RepositoryService.live, + zio.Scope.default + ) + + for { + res <- action + } yield assert(res)(equalTo(GetOrderByIdResponse.Ok(Order(id = Some(123), petId = Some(5), quantity = Some(3), status = Some(Order.Status.Placed))))) + }, + + /** + * Build a simple client, then hit the endpoint. + * This is just a sanity check to ensure our tests are wired up correctly against the inMemoryLayer + */ + test("getOrderById can find values in the static, in-memory repository") { + val action = (for { + routes <- ZIO.service[AppRoutes] + server <- Http4sServerLauncher.apply(routes.handler, None) + res <- StoreClient.httpClient(client, s"http://localhost:${server.address.getPort}").getOrderById(123L) + } yield res).provide( + AppRoutes.live, + StoreController.live, + RepositoryService.live, + zio.Scope.default + ) + + for { + res <- action + } yield assert(res)(equalTo(GetOrderByIdResponse.Ok(Order(id = Some(123), petId = Some(5), quantity = Some(3), status = Some(Order.Status.Placed))))) + }, + + /** + * A negative test, to ensure that we get a NotFound value back, instead of an exception. + */ + test("getOrderById correctly returns NotFound for incorrect ids") { + val action = (for { + routes <- ZIO.service[AppRoutes] + server <- Http4sServerLauncher.apply(routes.handler, None) + res <- StoreClient.httpClient(client, s"http://localhost:${server.address.getPort}").getOrderById(404L) + } yield res).provide( + AppRoutes.live, + StoreController.live, + RepositoryService.live, + zio.Scope.default + ) + + for res <- action yield assert(res)(equalTo(GetOrderByIdResponse.NotFound)) + }, + + /** + * Test mutating the in-memory Repository. + * This is a multi-phase test, + * - verifying the negative case, + * - mutating the store, + * - then verifying the positive case. + */ + test("placeOrder followed by getOrder") { + val myOrder = Order(id = Some(5), petId = Some(6), quantity = Some(1), status = Some(Order.Status.Placed)) + (for { + routes <- ZIO.service[AppRoutes] + server <- Http4sServerLauncher.apply(routes.handler, None) + first <- StoreClient.httpClient(client, s"http://localhost:${server.address.getPort}").getOrderById(5) + placedResponse <- StoreClient.httpClient(client, s"http://localhost:${server.address.getPort}").placeOrder(Some(myOrder)) + second <- StoreClient.httpClient(client, s"http://localhost:${server.address.getPort}").getOrderById(5) + } yield { + assert(first)(equalTo(GetOrderByIdResponse.NotFound)) && + assert(second)(equalTo(GetOrderByIdResponse.Ok(myOrder))) + }).provide( + AppRoutes.live, + StoreController.live, + RepositoryService.live, + zio.Scope.default + ) + + } + ) +} diff --git a/example-server/src/test/scala/example/tests/RoundTripSpec.scala b/example-server/src/test/scala/example/tests/RoundTripSpec.scala deleted file mode 100644 index 41158ac..0000000 --- a/example-server/src/test/scala/example/tests/RoundTripSpec.scala +++ /dev/null @@ -1,90 +0,0 @@ -package example.tests - -import zio._ -import zio.console._ -import zio.interop.catz._ -import zio.test._ -import zio.test.Assertion._ -import zio.test.environment._ - -import java.io.IOException - -import example.Controller -import example.client.store.StoreClient -import example.repository.Repository - -import example.client.store.GetOrderByIdResponse -import example.client.definitions.Order -import example.server.definitions.{ Order => ServerOrder } -import example.server.store.{ GetOrderByIdResponse => ServerGetOrderByIdResponse } - -import org.http4s.client.Client - -object RoundTripSpec extends DefaultRunnableSpec { - val initialInventory = Map( - "Kibble" -> 10, - "Treats" -> 3 - ) - val inventoryLayer = Ref.make(initialInventory).toManaged_.toLayer - - val initialOrders = Map( - 123L -> ServerOrder(id = Some(123L), petId = Some(5L), quantity = Some(3), status = Some(ServerOrder.Status.Placed)) - ) - val ordersLayer = Ref.make(initialOrders).toManaged_.toLayer - val inMemoryLayer = (inventoryLayer ++ ordersLayer) >>> example.repository.Repository.inMemory - - val buildStaticClient = for { - combinedRoutes <- Controller.combineRoutes - } yield StoreClient.httpClient(Client.fromHttpApp[RIO[Repository, *]](combinedRoutes), "http://localhost") - - def spec = suite("RoundTripSpec")( - /** - * This test hits the StoreHandler directly, bypassing all of the routing infrastructure in http4s - * This is useful for testing logic without the overhead of HTTP encoding and decoding, - * possibly in a situation where your tests are significantly slowed down by the round-trip through http4s. - */ - testM("Test controller functions without the client") { - for { - res <- Controller.handler.getOrderById(ServerGetOrderByIdResponse)(123L) - } yield assert(res)(equalTo(ServerGetOrderByIdResponse.Ok(ServerOrder(id=Some(123), petId=Some(5), quantity=Some(3), status=Some(ServerOrder.Status.Placed))))) - }, - /** - * Build a simple client, then hit the endpoint. - * This is just a sanity check to ensure our tests are wired up correctly against the inMemoryLayer - */ - testM("getOrderById can find values in the static, in-memory repository") { - for { - staticClient <- buildStaticClient - res <- staticClient.getOrderById(123L) - } yield assert(res)(equalTo(GetOrderByIdResponse.Ok(Order(id=Some(123), petId=Some(5), quantity=Some(3), status=Some(Order.Status.Placed))))) - }, - /** - * A negative test, to ensure that we get a NotFound value back, instead of an exception. - */ - testM("getOrderById correctly returns NotFound for incorrect ids") { - for { - staticClient <- buildStaticClient - res <- staticClient.getOrderById(404L) - } yield assert(res)(equalTo(GetOrderByIdResponse.NotFound)) - }, - /** - * Test mutating the in-memory Repository. - * This is a multi-phase test, - * - verifying the negative case, - * - mutating the store, - * - then verifying the positive case. - */ - testM("placeOrder followed by getOrder") { - val myOrder = Order(id=Some(5), petId=Some(6), quantity=Some(1), status=Some(Order.Status.Placed)) - for { - staticClient <- buildStaticClient - first <- staticClient.getOrderById(5) - placedResponse <- staticClient.placeOrder(Some(myOrder)) - second <- staticClient.getOrderById(5) - } yield ( - assert(first)(equalTo(GetOrderByIdResponse.NotFound)) && - assert(second)(equalTo(GetOrderByIdResponse.Ok(myOrder))) - ) - }, - ).provideSomeLayer[ZEnv](inMemoryLayer) -} diff --git a/project/guardrail.sbt b/project/guardrail.sbt index 92bee4d..048b6d1 100644 --- a/project/guardrail.sbt +++ b/project/guardrail.sbt @@ -1 +1 @@ -addSbtPlugin("com.twilio" % "sbt-guardrail" % "0.64.3") +addSbtPlugin("dev.guardrail" % "sbt-guardrail" % "0.74.0") diff --git a/server.yaml b/server.yaml index 4647884..3680438 100644 --- a/server.yaml +++ b/server.yaml @@ -36,6 +36,7 @@ paths: get: description: Returns a map of status codes to quantities operationId: getInventory + x-jvm-package: store responses: '200': content: @@ -61,6 +62,7 @@ paths: post: description: Place a new order in the store operationId: placeOrder + x-jvm-package: store requestBody: content: application/json: @@ -89,6 +91,7 @@ paths: description: For valid response try integer IDs with value < 1000. Anything above 1000 or nonintegers will generate API errors operationId: deleteOrder + x-jvm-package: store parameters: - description: ID of the order that needs to be deleted in: path @@ -111,6 +114,7 @@ paths: description: For valid response try integer IDs with value <= 5 or > 10. Other values will generated exceptions operationId: getOrderById + x-jvm-package: store parameters: - description: ID of order that needs to be fetched in: path