From 15438ead836f7e448241fe746bef2a8b6ecf4939 Mon Sep 17 00:00:00 2001 From: schetvertkov Date: Fri, 26 Aug 2022 12:53:41 +0300 Subject: [PATCH 1/5] zio 2 + scala 3 --- build.sbt | 37 ++-- .../src/main/scala/example/AppRoutes.scala | 27 +++ .../src/main/scala/example/Controller.scala | 168 +++++++----------- .../src/main/scala/example/Launch.scala | 42 ++++- .../httpServer/Http4sServerLauncher.scala | 28 +++ .../scala/example/httpServer/package.scala | 46 ----- .../repository/RepositoryService.scala | 67 +++++++ .../scala/example/repository/package.scala | 72 -------- .../scala/example/tests/RoundTripSpec.scala | 2 +- project/guardrail.sbt | 2 +- server.yaml | 4 + 11 files changed, 253 insertions(+), 242 deletions(-) create mode 100644 example-server/src/main/scala/example/AppRoutes.scala create mode 100644 example-server/src/main/scala/example/httpServer/Http4sServerLauncher.scala delete mode 100644 example-server/src/main/scala/example/httpServer/package.scala create mode 100644 example-server/src/main/scala/example/repository/RepositoryService.scala delete mode 100644 example-server/src/main/scala/example/repository/package.scala diff --git a/build.sbt b/build.sbt index 32ea8dd..5466f17 100644 --- a/build.sbt +++ b/build.sbt @@ -1,23 +1,32 @@ + +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") 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 +39,16 @@ 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), +// addCompilerPlugin("org.typelevel" %% "kind-projector" % "0.13.0" cross CrossVersion.full), // Better semantics for for comprehensions - addCompilerPlugin("com.olegpy" %% "better-monadic-for" % "0.3.1"), +// 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 +56,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/example/AppRoutes.scala b/example-server/src/main/scala/example/AppRoutes.scala new file mode 100644 index 0000000..2045b63 --- /dev/null +++ b/example-server/src/main/scala/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.* +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/example/Controller.scala b/example-server/src/main/scala/example/Controller.scala index 5bd5fb0..29a70a6 100644 --- a/example-server/src/main/scala/example/Controller.scala +++ b/example-server/src/main/scala/example/Controller.scala @@ -1,123 +1,91 @@ package example -import zio._ +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 -final case class GOBIRepoError(error: repository.GetOrderError) extends GetOrderByIdDownstreamErrors +enum GetOrderByIdDownstreamErrors: + case GOBIRepoError(error: UnknownOrder) extends GetOrderByIdDownstreamErrors -object Controller { +case class LiveStoreController(service: RepositoryService) extends StoreHandler[Task] { /** - * An effect which, when executed, gives a StoreResource (capable of transforming a StoreHandler into something bindable) + * getInventory + * + * Just grab from the repository + * + * Since we do not have multiple conflicting layers, we can just catchAll at the end to map errors */ - 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, *]] + 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")) } } /** - * Our HTTP server implementation, utilizing the Repository Layer + * 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. */ - 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")) - } + 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 + } - /** - * 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) - } + /** + * 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) - /** - * 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 + 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) } + } - 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._ + /** + * 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 - for { - storeResource <- makeStoreResource - } yield storeResource.routes(handler).orNotFound + action.merge } - 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) +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/example/Launch.scala b/example-server/src/main/scala/example/Launch.scala index ef21038..10db2e5 100644 --- a/example-server/src/main/scala/example/Launch.scala +++ b/example-server/src/main/scala/example/Launch.scala @@ -1,11 +1,37 @@ package example -import zio.{ App, ZEnv } - -object Launch extends App { - def run(args: List[String]) = - Controller - .inMemoryProg - .exitCode - .provideSomeLayer[ZEnv](httpServer.HttpServer.live) +import example.httpServer.Http4sServerLauncher +import example.repository.RepositoryService +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[AppRoutes & Scope, Throwable, Unit] = for { + appRoutes <- ZIO.service[AppRoutes] + _ <- Http4sServerLauncher(appRoutes.handler, config.port) + } yield () + + + action.provideSome[Scope]( + 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/example/httpServer/Http4sServerLauncher.scala b/example-server/src/main/scala/example/httpServer/Http4sServerLauncher.scala new file mode 100644 index 0000000..5ccd0a0 --- /dev/null +++ b/example-server/src/main/scala/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 +import zio.* +import zio.interop.catz.* +import zio.interop.catz.implicits.* + +object Http4sServerLauncher { + def apply(routes: HttpApp[Task], atPort: Int): ZIO[Any & Scope, Throwable, Server] = + ArmeriaServerBuilder[Task] + .bindHttp(atPort) + .withHttpApp("/", routes) + .resource.toScopedZIO + + val live: ZLayer[AppRoutes with ApplicationConfig with Scope, Throwable, Server] = ZLayer { + for { + config <- ZIO.service[ApplicationConfig] + routes <- ZIO.service[AppRoutes] + x <- apply(routes.handler, config.port) + } yield x + } + +} \ No newline at end of file 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/RepositoryService.scala b/example-server/src/main/scala/example/repository/RepositoryService.scala new file mode 100644 index 0000000..dcc2503 --- /dev/null +++ b/example-server/src/main/scala/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/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/example/tests/RoundTripSpec.scala b/example-server/src/test/scala/example/tests/RoundTripSpec.scala index 41158ac..eaea96e 100644 --- a/example-server/src/test/scala/example/tests/RoundTripSpec.scala +++ b/example-server/src/test/scala/example/tests/RoundTripSpec.scala @@ -35,7 +35,7 @@ object RoundTripSpec extends DefaultRunnableSpec { val buildStaticClient = for { combinedRoutes <- Controller.combineRoutes - } yield StoreClient.httpClient(Client.fromHttpApp[RIO[Repository, *]](combinedRoutes), "http://localhost") + } yield StoreClient.httpClient(Client.fromHttpApp[RIO[Repository.Service, *]](combinedRoutes), "http://localhost") def spec = suite("RoundTripSpec")( /** 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 From 991045b0efaf7c8d11f4c13c45abe5abce63e27e Mon Sep 17 00:00:00 2001 From: schetvertkov Date: Fri, 26 Aug 2022 23:13:56 +0300 Subject: [PATCH 2/5] client tests --- .../scala/example/tests/RoundTripSpec.scala | 97 ++++++++----------- 1 file changed, 42 insertions(+), 55 deletions(-) diff --git a/example-server/src/test/scala/example/tests/RoundTripSpec.scala b/example-server/src/test/scala/example/tests/RoundTripSpec.scala index eaea96e..a48187a 100644 --- a/example-server/src/test/scala/example/tests/RoundTripSpec.scala +++ b/example-server/src/test/scala/example/tests/RoundTripSpec.scala @@ -1,72 +1,60 @@ package example.tests -import zio._ -import zio.console._ -import zio.interop.catz._ -import zio.test._ -import zio.test.Assertion._ -import zio.test.environment._ +import zio.* +import zio.interop.catz.* 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 example.server.definitions.Order as ServerOrder +import org.http4s.armeria.client.ArmeriaClient +import scala.concurrent.duration.* import org.http4s.client.Client +import zio.test.Assertion.equalTo +import zio.test.* + +import java.net.URI -object RoundTripSpec extends DefaultRunnableSpec { - val initialInventory = Map( - "Kibble" -> 10, - "Treats" -> 3 - ) - val inventoryLayer = Ref.make(initialInventory).toManaged_.toLayer +object RoundTripSpec extends ZIOSpecDefault { - 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 + import org.http4s.armeria.client.ArmeriaClientBuilder - val buildStaticClient = for { - combinedRoutes <- Controller.combineRoutes - } yield StoreClient.httpClient(Client.fromHttpApp[RIO[Repository.Service, *]](combinedRoutes), "http://localhost") + val client: Client[Task] = + ArmeriaClient.apply() - def spec = suite("RoundTripSpec")( + 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. */ - testM("Test controller functions without the client") { + test("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))))) + res <- StoreClient.httpClient(client, "http://localhost:8080").getOrderById(123L) + } 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 */ - 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))))) - }, +// test("getOrderById can find values in the static, in-memory repository") { +// for { +// res <- StoreClient.httpClient(client, "").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("getOrderById correctly returns NotFound for incorrect ids") { +// for { +// res <- StoreClient.httpClient(client, "").getOrderById(404L) +// } yield assert(res)(equalTo(GetOrderByIdResponse.NotFound)) +// }, + /** * Test mutating the in-memory Repository. * This is a multi-phase test, @@ -74,17 +62,16 @@ object RoundTripSpec extends DefaultRunnableSpec { * - 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) +// test("placeOrder followed by getOrder") { +// val myOrder = Order(id = Some(5), petId = Some(6), quantity = Some(1), status = Some(Order.Status.Placed)) +// for { +// first <- StoreClient.httpClient(client, "").getOrderById(5) +// placedResponse <- StoreClient.httpClient(client, "").placeOrder(Some(myOrder)) +// second <- StoreClient.httpClient(client, "").getOrderById(5) +// } yield ( +// assert(first)(equalTo(GetOrderByIdResponse.NotFound)) && +// assert(second)(equalTo(GetOrderByIdResponse.Ok(myOrder))) +// ) +// } + ).provide() } From 1e15d2e2a1f5edfd2bb26106e4552831b088d5bd Mon Sep 17 00:00:00 2001 From: schetvertkov Date: Sat, 27 Aug 2022 07:34:07 +0300 Subject: [PATCH 3/5] fixed the tests --- .../src/main/scala/example/Launch.scala | 9 +- .../httpServer/Http4sServerLauncher.scala | 8 +- .../scala/example/tests/RoundTripSpec.scala | 94 ++++++++++++++----- 3 files changed, 78 insertions(+), 33 deletions(-) diff --git a/example-server/src/main/scala/example/Launch.scala b/example-server/src/main/scala/example/Launch.scala index 10db2e5..15a9aa5 100644 --- a/example-server/src/main/scala/example/Launch.scala +++ b/example-server/src/main/scala/example/Launch.scala @@ -2,6 +2,7 @@ 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} @@ -13,13 +14,13 @@ object Launch extends zio.ZIOAppDefault { def runWithConfig(config: ApplicationConfig): ZIO[Any with Scope, Throwable, Unit] = { - val action: ZIO[AppRoutes & Scope, Throwable, Unit] = for { - appRoutes <- ZIO.service[AppRoutes] - _ <- Http4sServerLauncher(appRoutes.handler, config.port) + 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 diff --git a/example-server/src/main/scala/example/httpServer/Http4sServerLauncher.scala b/example-server/src/main/scala/example/httpServer/Http4sServerLauncher.scala index 5ccd0a0..58395e7 100644 --- a/example-server/src/main/scala/example/httpServer/Http4sServerLauncher.scala +++ b/example-server/src/main/scala/example/httpServer/Http4sServerLauncher.scala @@ -5,15 +5,15 @@ import example.AppRoutes import example.Launch.ApplicationConfig import org.http4s.HttpApp import org.http4s.armeria.server.ArmeriaServerBuilder -import org.http4s.server.Server +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: Int): ZIO[Any & Scope, Throwable, Server] = + def apply(routes: HttpApp[Task], atPort: Option[Int]): ZIO[Any & Scope, Throwable, Server] = ArmeriaServerBuilder[Task] - .bindHttp(atPort) + .bindHttp(atPort.fold(0)(identity)) .withHttpApp("/", routes) .resource.toScopedZIO @@ -21,7 +21,7 @@ object Http4sServerLauncher { for { config <- ZIO.service[ApplicationConfig] routes <- ZIO.service[AppRoutes] - x <- apply(routes.handler, config.port) + x <- apply(routes.handler, Option(config.port)) } yield x } diff --git a/example-server/src/test/scala/example/tests/RoundTripSpec.scala b/example-server/src/test/scala/example/tests/RoundTripSpec.scala index a48187a..7c612bc 100644 --- a/example-server/src/test/scala/example/tests/RoundTripSpec.scala +++ b/example-server/src/test/scala/example/tests/RoundTripSpec.scala @@ -1,5 +1,7 @@ package example.tests +import example.Launch.ApplicationConfig +import example.{AppRoutes, StoreController} import zio.* import zio.interop.catz.* @@ -7,11 +9,14 @@ 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.* @@ -21,8 +26,7 @@ object RoundTripSpec extends ZIOSpecDefault { import org.http4s.armeria.client.ArmeriaClientBuilder - val client: Client[Task] = - ArmeriaClient.apply() + val client: Client[Task] = ArmeriaClient.apply() override def spec: Spec[TestEnvironment with Scope, Any] = suite("RoundTripSpec")( /** @@ -31,8 +35,20 @@ object RoundTripSpec extends ZIOSpecDefault { * 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 <- StoreClient.httpClient(client, "http://localhost:8080").getOrderById(123L) + res <- action } yield assert(res)(equalTo(GetOrderByIdResponse.Ok(Order(id = Some(123), petId = Some(5), quantity = Some(3), status = Some(Order.Status.Placed))))) }, @@ -40,20 +56,40 @@ object RoundTripSpec extends ZIOSpecDefault { * 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") { -// for { -// res <- StoreClient.httpClient(client, "").getOrderById(123L) -// } yield assert(res)(equalTo(GetOrderByIdResponse.Ok(Order(id = Some(123), petId = Some(5), quantity = Some(3), status = Some(Order.Status.Placed))))) -// }, + 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") { -// for { -// res <- StoreClient.httpClient(client, "").getOrderById(404L) -// } yield assert(res)(equalTo(GetOrderByIdResponse.NotFound)) -// }, + 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. @@ -62,16 +98,24 @@ object RoundTripSpec extends ZIOSpecDefault { * - 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 { -// first <- StoreClient.httpClient(client, "").getOrderById(5) -// placedResponse <- StoreClient.httpClient(client, "").placeOrder(Some(myOrder)) -// second <- StoreClient.httpClient(client, "").getOrderById(5) -// } yield ( -// assert(first)(equalTo(GetOrderByIdResponse.NotFound)) && -// assert(second)(equalTo(GetOrderByIdResponse.Ok(myOrder))) -// ) -// } - ).provide() + 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 + ) + + } + ) } From 403f5f269c82f1789403c7f47abe161976df5b7e Mon Sep 17 00:00:00 2001 From: schetvertkov Date: Sat, 3 Sep 2022 15:21:34 +0300 Subject: [PATCH 4/5] separate scala 2 and scala 3 sources --- build.sbt | 17 ++- .../src/main/scala-2/example/AppRoutes.scala | 27 ++++ .../src/main/scala-2/example/Controller.scala | 97 ++++++++++++++ .../src/main/scala-2/example/Launch.scala | 37 ++++++ .../httpServer/Http4sServerLauncher.scala | 28 +++++ .../repository/RepositoryService.scala | 72 +++++++++++ .../example/AppRoutes.scala | 7 +- .../example/Controller.scala | 7 +- .../{scala => scala-3}/example/Launch.scala | 0 .../httpServer/Http4sServerLauncher.scala | 0 .../repository/RepositoryService.scala | 25 ++-- .../scala-2/example/tests/RoundTripSpec.scala | 118 ++++++++++++++++++ .../example/tests/RoundTripSpec.scala | 0 13 files changed, 415 insertions(+), 20 deletions(-) create mode 100644 example-server/src/main/scala-2/example/AppRoutes.scala create mode 100644 example-server/src/main/scala-2/example/Controller.scala create mode 100644 example-server/src/main/scala-2/example/Launch.scala create mode 100644 example-server/src/main/scala-2/example/httpServer/Http4sServerLauncher.scala create mode 100644 example-server/src/main/scala-2/example/repository/RepositoryService.scala rename example-server/src/main/{scala => scala-3}/example/AppRoutes.scala (89%) rename example-server/src/main/{scala => scala-3}/example/Controller.scala (95%) rename example-server/src/main/{scala => scala-3}/example/Launch.scala (100%) rename example-server/src/main/{scala => scala-3}/example/httpServer/Http4sServerLauncher.scala (100%) rename example-server/src/main/{scala => scala-3}/example/repository/RepositoryService.scala (83%) create mode 100644 example-server/src/test/scala-2/example/tests/RoundTripSpec.scala rename example-server/src/test/{scala => scala-3}/example/tests/RoundTripSpec.scala (100%) diff --git a/build.sbt b/build.sbt index 5466f17..522c4be 100644 --- a/build.sbt +++ b/build.sbt @@ -11,8 +11,7 @@ ThisBuild / organization := "se.hardchee" 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 @@ -39,10 +38,18 @@ 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")) 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/example/AppRoutes.scala b/example-server/src/main/scala-3/example/AppRoutes.scala similarity index 89% rename from example-server/src/main/scala/example/AppRoutes.scala rename to example-server/src/main/scala-3/example/AppRoutes.scala index 2045b63..1095eb8 100644 --- a/example-server/src/main/scala/example/AppRoutes.scala +++ b/example-server/src/main/scala-3/example/AppRoutes.scala @@ -6,15 +6,16 @@ import zio.{Task, ZIO, ZLayer} import zio.interop.catz.* import scala.util.chaining.* -trait AppRoutes: +trait AppRoutes { def handler: HttpApp[Task] +} final case class LiveAppRoutes(storeHandler: StoreHandler[Task]) extends AppRoutes { - override def handler: HttpApp[Task] = + override def handler: HttpApp[Task] = { val routes = (new StoreResource[Task]).routes(storeHandler) routes.orNotFound - + } } object AppRoutes { diff --git a/example-server/src/main/scala/example/Controller.scala b/example-server/src/main/scala-3/example/Controller.scala similarity index 95% rename from example-server/src/main/scala/example/Controller.scala rename to example-server/src/main/scala-3/example/Controller.scala index 29a70a6..a9ad972 100644 --- a/example-server/src/main/scala/example/Controller.scala +++ b/example-server/src/main/scala-3/example/Controller.scala @@ -6,8 +6,11 @@ import example.server.store.StoreHandler import example.server.store.StoreResource.{DeleteOrderResponse, GetInventoryResponse, GetOrderByIdResponse, PlaceOrderResponse} import zio.* -enum GetOrderByIdDownstreamErrors: - case GOBIRepoError(error: UnknownOrder) extends GetOrderByIdDownstreamErrors +sealed trait GetOrderByIdDownstreamErrors + +object GetOrderByIdDownstreamErrors { + case class GOBIRepoError(error: UnknownOrder) extends GetOrderByIdDownstreamErrors +} case class LiveStoreController(service: RepositoryService) extends StoreHandler[Task] { diff --git a/example-server/src/main/scala/example/Launch.scala b/example-server/src/main/scala-3/example/Launch.scala similarity index 100% rename from example-server/src/main/scala/example/Launch.scala rename to example-server/src/main/scala-3/example/Launch.scala diff --git a/example-server/src/main/scala/example/httpServer/Http4sServerLauncher.scala b/example-server/src/main/scala-3/example/httpServer/Http4sServerLauncher.scala similarity index 100% rename from example-server/src/main/scala/example/httpServer/Http4sServerLauncher.scala rename to example-server/src/main/scala-3/example/httpServer/Http4sServerLauncher.scala diff --git a/example-server/src/main/scala/example/repository/RepositoryService.scala b/example-server/src/main/scala-3/example/repository/RepositoryService.scala similarity index 83% rename from example-server/src/main/scala/example/repository/RepositoryService.scala rename to example-server/src/main/scala-3/example/repository/RepositoryService.scala index dcc2503..6fd5327 100644 --- a/example-server/src/main/scala/example/repository/RepositoryService.scala +++ b/example-server/src/main/scala-3/example/repository/RepositoryService.scala @@ -3,11 +3,16 @@ package example.repository import example.server.definitions.Order import zio.{IO, Ref, UIO, ZIO, ZLayer} -enum GetInventoryError: - case StockroomUnavailable +sealed trait GetInventoryError -enum PlaceOrderError: - case InsufficientQuantity(id: Long) +object GetInventoryError { + case object StockroomUnavailable extends GetInventoryError +} + +sealed trait PlaceOrderError +object PlaceOrderError { + case class InsufficientQuantity(id: Long) extends PlaceOrderError +} case class UnknownOrder(id: Long) @@ -37,16 +42,16 @@ case class InMemoryRepositoryService(inventory: Ref[Map[String, Int]], orders: R } override def getOrder(id: Long): IO[UnknownOrder, Order] = - for + for { currentOrders <- orders.get order <- ZIO.fromOption(currentOrders.get(id)).orElseFail(UnknownOrder(id)) - yield order + } yield order override def deleteOrder(id: Long): IO[AlreadyDeleted | UnknownOrder, Unit] = - for + for { order <- orders.modify(all => (all.get(id), all - id)) foundOrder <- ZIO.fromOption(order).orElseFail(UnknownOrder(id)) - yield () + } yield () } @@ -59,9 +64,9 @@ object RepositoryService { ) val live: ZLayer[Any, Nothing, RepositoryService] = ZLayer { - for + for { inventory <- zio.Ref.make[Map[String, Int]](initialInventory) orders <- zio.Ref.make[Map[Long, Order]](initialOrders) - yield InMemoryRepositoryService(inventory, orders) + } yield InMemoryRepositoryService(inventory, orders) } } 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/example/tests/RoundTripSpec.scala b/example-server/src/test/scala-3/example/tests/RoundTripSpec.scala similarity index 100% rename from example-server/src/test/scala/example/tests/RoundTripSpec.scala rename to example-server/src/test/scala-3/example/tests/RoundTripSpec.scala From ef8c970d160bf3a642b5545bfc91c756a85c8735 Mon Sep 17 00:00:00 2001 From: schetvertkov Date: Sat, 3 Sep 2022 15:40:11 +0300 Subject: [PATCH 5/5] separate scala 2 and scala 3 sources --- .../src/main/scala-3/example/AppRoutes.scala | 4 +-- .../src/main/scala-3/example/Controller.scala | 12 ++++----- .../src/main/scala-3/example/Launch.scala | 5 +--- .../httpServer/Http4sServerLauncher.scala | 6 ++--- .../repository/RepositoryService.scala | 25 ++++++++----------- 5 files changed, 21 insertions(+), 31 deletions(-) diff --git a/example-server/src/main/scala-3/example/AppRoutes.scala b/example-server/src/main/scala-3/example/AppRoutes.scala index 1095eb8..de74a82 100644 --- a/example-server/src/main/scala-3/example/AppRoutes.scala +++ b/example-server/src/main/scala-3/example/AppRoutes.scala @@ -12,10 +12,10 @@ trait AppRoutes { final case class LiveAppRoutes(storeHandler: StoreHandler[Task]) extends AppRoutes { - override def handler: HttpApp[Task] = { + override def handler: HttpApp[Task] = val routes = (new StoreResource[Task]).routes(storeHandler) routes.orNotFound - } + } object AppRoutes { diff --git a/example-server/src/main/scala-3/example/Controller.scala b/example-server/src/main/scala-3/example/Controller.scala index a9ad972..6a98dea 100644 --- a/example-server/src/main/scala-3/example/Controller.scala +++ b/example-server/src/main/scala-3/example/Controller.scala @@ -22,9 +22,7 @@ case class LiveStoreController(service: RepositoryService) extends StoreHandler[ * 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) + 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")) @@ -38,7 +36,7 @@ case class LiveStoreController(service: RepositoryService) extends StoreHandler[ * 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 { + 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) @@ -46,7 +44,7 @@ case class LiveStoreController(service: RepositoryService) extends StoreHandler[ res <- service.placeOrder(id, petId, quantity).mapError { case PlaceOrderError.InsufficientQuantity(id) => respond.MethodNotAllowed } // TODO: 405 isn't really applicable here - } yield respond.Ok(res) + yield respond.Ok(res) action.merge // Second strategy of error handling, mapError to PlaceOrderResponses, then merge them all together } @@ -75,12 +73,12 @@ case class LiveStoreController(service: RepositoryService) extends StoreHandler[ * those explicitly and use the .merge technique from placeOrder */ def deleteOrder(respond: DeleteOrderResponse.type)(orderId: Long): Task[DeleteOrderResponse] = { - val action = for { + val action = for _ <- service.deleteOrder(orderId).mapError { case repository.UnknownOrder(id) => respond.NotFound case repository.AlreadyDeleted(id) => respond.BadRequest } - } yield respond.Accepted + yield respond.Accepted action.merge } diff --git a/example-server/src/main/scala-3/example/Launch.scala b/example-server/src/main/scala-3/example/Launch.scala index 15a9aa5..7095090 100644 --- a/example-server/src/main/scala-3/example/Launch.scala +++ b/example-server/src/main/scala-3/example/Launch.scala @@ -13,10 +13,7 @@ object Launch extends zio.ZIOAppDefault { 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 () + val action: ZIO[Server, Nothing, Unit] = for appRoutes <- ZIO.service[Server] yield () action.provideSome[Scope]( ZLayer.succeed(config), diff --git a/example-server/src/main/scala-3/example/httpServer/Http4sServerLauncher.scala b/example-server/src/main/scala-3/example/httpServer/Http4sServerLauncher.scala index 58395e7..775f39b 100644 --- a/example-server/src/main/scala-3/example/httpServer/Http4sServerLauncher.scala +++ b/example-server/src/main/scala-3/example/httpServer/Http4sServerLauncher.scala @@ -18,11 +18,11 @@ object Http4sServerLauncher { .resource.toScopedZIO val live: ZLayer[AppRoutes with ApplicationConfig with Scope, Throwable, Server] = ZLayer { - for { + for config <- ZIO.service[ApplicationConfig] routes <- ZIO.service[AppRoutes] - x <- apply(routes.handler, Option(config.port)) - } yield x + 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 index 6fd5327..dcc2503 100644 --- a/example-server/src/main/scala-3/example/repository/RepositoryService.scala +++ b/example-server/src/main/scala-3/example/repository/RepositoryService.scala @@ -3,16 +3,11 @@ package example.repository import example.server.definitions.Order import zio.{IO, Ref, UIO, ZIO, ZLayer} -sealed trait GetInventoryError +enum GetInventoryError: + case StockroomUnavailable -object GetInventoryError { - case object StockroomUnavailable extends GetInventoryError -} - -sealed trait PlaceOrderError -object PlaceOrderError { - case class InsufficientQuantity(id: Long) extends PlaceOrderError -} +enum PlaceOrderError: + case InsufficientQuantity(id: Long) case class UnknownOrder(id: Long) @@ -42,16 +37,16 @@ case class InMemoryRepositoryService(inventory: Ref[Map[String, Int]], orders: R } override def getOrder(id: Long): IO[UnknownOrder, Order] = - for { + for currentOrders <- orders.get order <- ZIO.fromOption(currentOrders.get(id)).orElseFail(UnknownOrder(id)) - } yield order + yield order override def deleteOrder(id: Long): IO[AlreadyDeleted | UnknownOrder, Unit] = - for { + for order <- orders.modify(all => (all.get(id), all - id)) foundOrder <- ZIO.fromOption(order).orElseFail(UnknownOrder(id)) - } yield () + yield () } @@ -64,9 +59,9 @@ object RepositoryService { ) val live: ZLayer[Any, Nothing, RepositoryService] = ZLayer { - for { + for inventory <- zio.Ref.make[Map[String, Int]](initialInventory) orders <- zio.Ref.make[Map[Long, Order]](initialOrders) - } yield InMemoryRepositoryService(inventory, orders) + yield InMemoryRepositoryService(inventory, orders) } }