# ZIO HTTP > A next-generation Scala framework for building scalable, correct, and efficient HTTP clients and servers This file contains all documentation content in a single document following the llmstxt.org standard. ## ZIO HTTP Examples This section aims to demonstrate the usage of key concepts and ideas in the ZIO HTTP library with examples. --- ## Introduction to ZIO HTTP ZIO HTTP is a scala library for building http apps. It is powered by ZIO and [Netty](https://netty.io/) and aims at being the defacto solution for writing, highly scalable and performant web applications using idiomatic Scala. ZIO HTTP is designed in terms of **HTTP as function**, where both server and client are a function from a request to a response, with a focus on type safety, composability, and testability. [![Development](https://img.shields.io/badge/Project%20Stage-Development-green.svg)](https://github.com/zio/zio/wiki/Project-Stages) ![CI Badge](https://github.com/zio/zio-http/workflows/Continuous%20Integration/badge.svg) [![Sonatype Releases](https://img.shields.io/nexus/r/https/oss.sonatype.org/dev.zio/zio-http_2.13.svg?label=Sonatype%20Release)](https://oss.sonatype.org/content/repositories/releases/dev/zio/zio-http_2.13/) [![Sonatype Snapshots](https://img.shields.io/nexus/s/https/oss.sonatype.org/dev.zio/zio-http_2.13.svg?label=Sonatype%20Snapshot)](https://oss.sonatype.org/content/repositories/snapshots/dev/zio/zio-http_2.13/) [![javadoc](https://javadoc.io/badge2/dev.zio/zio-http-docs_2.13/javadoc.svg)](https://javadoc.io/doc/dev.zio/zio-http-docs_2.13) [![ZIO Http](https://img.shields.io/github/stars/zio/zio-http?style=social)](https://github.com/zio/zio-http) Some of the key features of ZIO HTTP are: **ZIO Native**: ZIO HTTP is built atop ZIO, a type-safe, composable, and asynchronous effect system for Scala. It inherits all the benefits of ZIO, including testability, composability, and type safety. **Cloud-Native**: ZIO HTTP is designed for cloud-native environments and supports building highly scalable and performant web applications. Built atop ZIO, it features built-in support for concurrency, parallelism, resource management, error handling, structured logging, configuration management, and metrics instrumentation. **Imperative and Declarative Endpoints**: ZIO HTTP provides a declarative API for defining HTTP endpoints besides the imperative API. With imperative endpoints, both the shape of the endpoint and the logic are defined together, while with declarative endpoints, the description of the endpoint is separated from its logic. Developers can choose the style that best fit their needs. **Type-Driven API Design**: Beside the fact that ZIO HTTP supports declarative endpoint descriptions, it also provides a type-driven API design that leverages Scala's type system to ensure correctness and safety at compile time. So the implementation of the endpoint is type-checked against the description of the endpoint. **Middleware Support**: ZIO HTTP offers middleware support for incorporating cross-cutting concerns such as logging, metrics, authentication, and more into your services. **Error Handling**: Built-in support exists for handling errors at the HTTP layer, distinguishing between handled and unhandled errors. **WebSockets**: Built-in support for WebSockets allows for the creation of real-time applications using ZIO HTTP. **Testkit**: ZIO HTTP provides first-class testing utilities that facilitate test writing without requiring a live server instance. **Interoperability**: Interoperability with existing Scala/Java libraries is provided, enabling seamless integration with functionality from the Scala/Java ecosystem through the importation of blocking and non-blocking operations. **JSON and Binary Codecs**: Built-in support for ZIO Schema enables encoding and decoding of request/response bodies, supporting various data types including JSON, Protobuf, Avro, and Thrift. **Template System**: A built-in DSL facilitates writing HTML templates using Scala code. **OpenAPI Support**: Built-in support is available for generating OpenAPI documentation for HTTP applications, and conversely, for generating HTTP endpoints from OpenAPI documentation. **ZIO HTTP CLI**: Command-line applications can be built to interact with HTTP APIs by leveraging the power of [ZIO CLI](https://zio.dev/zio-cli) and ZIO HTTP. ## Installation Setup via `build.sbt`: ```scala libraryDependencies += "dev.zio" %% "zio-http" % "3.8.1" ``` **NOTES ON VERSIONING:** - Older library versions `1.x` or `2.x` with organization `io.d11` of ZIO HTTP are derived from Dream11, the organization that donated ZIO HTTP to the ZIO organization in 2022. - Newer library versions, starting in 2023 and resulting from the [ZIO organization](https://dev.zio) started with `0.0.x`, reaching `1.0.0` release candidates in April of 2023 ## Getting Started ZIO HTTP provides a simple and expressive API for building HTTP applications. It supports both server and client-side APIs. Let's see how it is simple to build a greeting server and call it using the client API. ### Greeting Server The following example demonstrates how to build a simple greeting server. It contains 2 routes: one on the root path, it responds with a fixed string, and one route on the path `/greet` that responds with a greeting message based on the query parameter `name`. ```scala object GreetingServer extends ZIOAppDefault { val routes = Routes( Method.GET / Root -> handler(Response.text("Greetings at your service")), Method.GET / "greet" -> handler { (req: Request) => val name = req.queryOrElse[String]("name", "World") Response.text(s"Hello $name!") } ) def run = Server.serve(routes).provide(Server.default) } ``` ### Greeting Client The following example demonstrates how to call the greeting server using the ZIO HTTP client: ```scala object GreetingClient extends ZIOAppDefault { val app = for { client <- ZIO.serviceWith[Client](_.host("localhost").port(8080)) request = Request.get("greet").addQueryParam("name", "John") response <- client.batched(request) _ <- response.body.asString.debug("Response") } yield () def run = app.provide(Client.default) } ``` --- ## Request-scoped Context Management When building web applications with ZIO HTTP, you often need to share contextual information across different layers of your request processing pipeline. This might include user authentication data, correlation IDs for distributed tracing, session information, or request metadata. The challenge is making this data available throughout the request lifecycle without threading it through every function parameter or resorting to global mutable state. ZIO HTTP offers two complementary approaches to managing request-scoped context, each with its own strengths, trade-offs, and use-cases. Understanding when to use each approach will help you build applications that are both maintainable and type-safe: 1. **[ZIO Environment with HandlerAspect](zio-environment.md)** leverages ZIO's type-safe dependency injection system to propagate request-scoped context through the `HandlerAspect` stack and finally the handlers. `HandlerAspect` produces typed context values that become part of the ZIO environment, making them accessible to handlers via `ZIO.service` or with the `withContext` DSL. This approach provides compile-time guarantees that all required context is present, catching missing dependencies before your code ever runs. The ZIO environment approach excels when compile-time safety is paramount, and it is specifically used for passing context from middlewares to handlers. The type system ensures that handlers explicitly declare their context requirements, making it impossible to forget to apply necessary middleware. This catches entire classes of runtime errors at compile time. The approach also provides better documentation through types—when you see `Handler[User & RequestId, ...]`, you immediately know what context or service the handler requires. 2. **[RequestStore](request-store.md)** provides a FiberRef-based storage mechanism that acts like a request-scoped key-value store. You can store and retrieve typed values at any point during request processing without explicit parameter passing. The data is automatically isolated per request and cleaned up when the request completes. `RequestStore` shines when you don't need compile-time type safety. Unlike the previous approach, the `RequestStore` is not tied to the middleware stack, allowing you to store and retrieve context at any point of the request lifecycle without changing handler signatures. This is helpful when you don't want to pass the context through every layer explicitly, and you don't need compile-time guarantees about context presence. It's also ideal when you're working with legacy code or integrating with systems where compile-time type safety is less critical than ease of integration. The pattern feels familiar to developers coming from other web frameworks that use context managers with thread-local storage. --- ## Integration of Datastar with ZIO HTTP [Datastar](https://data-star.dev/) is a hypermedia-driven framework for building reactive web applications with minimal JavaScript. The `zio-http-datastar-sdk` integrates Datastar with ZIO HTTP, bringing these capabilities to the ZIO ecosystem and allowing developers to create server-driven UIs with minimal frontend complexity. In Datastar, the server sends HTML elements that are integrated into the web page. Instead of building a data based API (JSON, XML, etc.) and rendering HTML on the client, the rendering happens on the server, and the HTML elements—including hypermedia controls—are sent to the browser. This matters because it solves a critical problem in modern web development: building interactive, real-time applications traditionally requires heavy frontend frameworks and complex state synchronization. The Datastar integration provides a simpler alternative for server-driven applications where state lives on the backend, updates flow via SSE or HTTP transactions, and the frontend remains lightweight (about 10.7 KB). ## Datastar Overview Datastar uses declarative `data-*` HTML attributes to define the application state and behavior on the client side. Datastar uses signals to represent reactive state variables that can be updated both on the client and server sides. Signals are prefixed with `$` (like `$username`, `$count`). These signals are automatically sent to the backend with each request, and the server can patch them by sending signal patches back to the client. For example, when a user types into an input field bound with `data-bind:email`, the `$email` signal updates locally and gets transmitted to the server with subsequent requests. The server can then push signal updates back using JSON Merge Patch (RFC 7396), or send HTML fragments that morph into the DOM. This flow can happen over SSE connections or regular HTTP transactions. Datastar shines in scenarios where you want to build dynamic, real-time web applications without the overhead of heavy frontend frameworks. Here are some common use cases: - Chat messages appearing live. - Monitoring logs, metrics, or notifications. - Live search results that update as you type. - Real-time dashboard panels updating from streaming endpoints. ## Reactive Hypermedia with ZIO HTTP The `zio-http-datastar-sdk` provides both server-side and client-side utilities to provide a unified web development experience within the ZIO ecosystem: 1. The server-side API shields developers from low-level SSE protocol details, providing server-sent event generators for creating [Datastar SSE event types](https://data-star.dev/reference/sse_events) such as patching elements and signals, and executing scripts. 2. The client-side API offers a ZIO-friendly way to embed Datastar [attributes](https://data-star.dev/reference/attributes) into HTML responses, making it easy to create reactive UIs that seamlessly integrate with ZIO HTTP's templating capabilities. ## Installation To use the Datastar SDK with ZIO HTTP, add the following dependency to your `build.sbt` file: ```scala libraryDependencies += "dev.zio" %% "zio-http-datastar-sdk" % "3.8.1" ``` You also have to include the Datastar JavaScript client module in your HTML pages. You can do this by adding the following script tag to your HTML: ```scala // Uses the default version of datastar from the zio-http-datastar-sdk module head(datastarScript) // Or specify a custom version of datastar head(datastarScript(version = "1.2.3")) ``` Pick the proper version of the module according to the [installation instructions](https://data-star.dev/guide/getting_started#installation) in the Datastar's documentation. ## Basic Usage After reading the [Getting Started](https://data-star.dev/guide/getting_started) guide and learning the basics of Datastar, you are ready to dive into examples and the reference documentation. ## Documentation Structure The Datastar SDK reference is organized into the following sections: - **[HTML Attributes](./attributes.md)** — Complete reference of all `data-*` attributes and how to use them for declaring state and behavior - **[Extracting Signals](./signals.md)** — How to read client signal values from requests on the server - **[Event Generation](./server-api.md)** — Server-side helpers for sending updates via single-shot responses or SSE streaming - **[Examples](./examples.md)** — Runnable example applications demonstrating key patterns and features --- ## ZIO HTTP Reference This section offers a detailed reference for the essential concepts and ideas in the ZIO HTTP library. --- ## ZIO HTTP Testkit `zio-http-testkit` provides testing infrastructure for ZIO HTTP applications, enabling developers to test HTTP logic without real servers, mock external dependencies, and validate WebSocket communication. Core types: `TestServer`, `TestClient`, `TestChannel`, `HttpTestAspect`. Here's what you can do with the testkit: ```scala // Test a server route directly val routes = Routes(Method.GET / "users" -> Handler.text("Alice")) // Test a client that calls external services val testMockClient = for { _ <- TestClient.addRoute(Method.GET / "api" -> handler(Response.text("mock"))) client <- ZIO.service[Client] response <- client(Request.get(URL.root / "api")) } yield assertTrue(response.status == Status.Ok) // Test WebSocket bidirectional messaging val echoServer: WebSocketApp[Any] = Handler.webSocket { channel => channel.receiveAll { case Read(WebSocketFrame.Text(msg)) => channel.send(Read(WebSocketFrame.text(s"Echo: $msg"))) case _ => ZIO.unit } } ``` ## Motivation Testing HTTP applications is fundamentally different from testing pure functions. Traditional approaches are painful: - **Real servers in tests** — Slow (seconds per test), hard to debug (network I/O), difficult to test edge cases (how do you simulate a timeout?). - **Mocking HTTP libraries** — You're not really testing HTTP logic, just that you call the mock correctly. If the mock diverges from real behavior, tests pass but production fails. - **Tests depending on external services** — Slow, flaky (services go down), hard to run locally, can't easily test error paths. `zio-http-testkit` solves this by treating routes and WebSocket handlers as pure functions. You test them directly without any server infrastructure, making tests fast, deterministic, and realistic. ## Installation Add this dependency to your `build.sbt`: ```scala libraryDependencies += "dev.zio" %% "zio-http-testkit" % "3.8.1" % Test ``` Scala versions: 2.13.x and 3.x ## Overview The testkit provides four core types working together to cover the full testing spectrum: - **`TestServer`** — Simulates an HTTP server. Configure routes, make HTTP requests via a client, verify responses. Use for integration testing multiple routes together. - **`TestClient`** — Simulates an HTTP client. Mock external service responses, verify your code makes correct HTTP calls. Use when your application depends on external APIs. - **`TestChannel`** — In-memory bidirectional message channel. Test WebSocket handlers with realistic message exchange patterns, no actual network. - **`HttpTestAspect`** — Test utility for configuring HTTP runtime modes (Dev, Prod, Preprod). Use when your handler behavior varies by deployment mode. ## How They Work Together The four core types work together in layers, each appropriate for different testing scenarios: ### Layer 1: Unit Testing with Direct Route Testing (90% of tests) When you want the fastest feedback, invoke a `Handler` as a pure function: ``` Request ──> Route.runZIO ──> Response ``` No server infrastructure. Each test runs in isolation, testing business logic, routing patterns, and data transformations at lightning speed. Perfect for the majority of your test suite. Example: ```scala title="zio-http-example-testing/src/test/scala/example/testing/UnitTestingIndividualHandlers.scala" package example.testing object UnitTestingIndividualHandlers extends ZIOSpecDefault { def spec = test("returns user data") { val routes = Routes( Method.GET / "users" / int("id") -> handler { (id: Int, _: Request) => ZIO.succeed(Response.json(s"""{"id": $id, "name": "User $id"}""")) } ) for { response <- routes.runZIO(Request.get(URL(Path.root / "users" / "42"))) body <- response.body.asString } yield assertTrue(body.contains("User 42")) } } ``` ### Layer 2: Integration Testing with External Mocking via TestClient (5% of tests) When your handler calls external services, `TestClient` intercepts those calls: ``` Your Code ──> TestClient ──> Mocked Response (configured) ``` Your code uses the standard `Client` interface, unaware it's talking to a test mock. You control every response—success, failure, timeouts, anything. Test payment processors, third-party APIs, and resilience to failure without real network calls. Example: ```scala title="zio-http-example-testing/src/test/scala/example/testing/MockingExternalDependencies.scala" package example.testing object MockingExternalDependencies extends ZIOSpecDefault { def spec = test("calls payment processor correctly") { for { client <- ZIO.service[Client] // Mock the payment processor _ <- TestClient.addRequestResponse( Request.post(URL.root / "charge", Body.fromString("""{"amount": 100}""")), Response.json("""{"transaction_id": "tx_123", "status": "approved"}""") ) // Your code calls the mocked client response <- client(Request.post(URL.root / "charge", Body.fromString("""{"amount": 100}"""))) body <- response.body.asString } yield assertTrue(body.contains("tx_123")) }.provide(TestClient.layer, Scope.default) } ``` ### Layer 3: Full Integration Testing with TestServer (5% of tests) When you need the complete picture—multiple `Route`s working together, state persisting across requests—bring in `TestServer`: ``` HTTP Request ──> TestServer ──> Route Matching ──> Handler ──> Response (localhost / in-process) ``` This is where you test feature workflows. User registration followed by email verification. API calls in sequence. Routes interacting as they would in production. It's the slowest approach, but it catches integration issues that simpler tests miss. **Examples:** Multiple routes working together: ```scala title="zio-http-example-testing/src/test/scala/example/testing/IntegrationTestingMultipleRoutes.scala" package example.testing object IntegrationTestingMultipleRoutes extends ZIOSpecDefault { def spec = test("create and retrieve user") { for { client <- ZIO.service[Client] port <- ZIO.serviceWithZIO[Server](_.port) users <- Ref.make(Map.empty[Int, String]) nextId <- Ref.make(0) // Configure create and read endpoints _ <- TestServer.addRoutes { Routes( Method.POST / "users" -> handler { (req: Request) => req.body.asString .flatMap { name => for { id <- nextId.updateAndGet(_ + 1) _ <- users.update(_ + (id -> name)) } yield Response.status(Status.Created).addHeader("X-ID", id.toString) } .catchAll { _ => ZIO.succeed(Response.status(Status.BadRequest)) } }, Method.GET / "users" / int("id") -> handler { (id: Int, _: Request) => users.get.map { store => store.get(id) match { case Some(name) => Response.json(s"""{"id": $id, "name": "$name"}""") case None => Response.notFound } } } ) } // Create a user createResp <- client(Request.post(URL.root.port(port) / "users", Body.fromString("Alice"))) userId = createResp.headers.get("X-ID").flatMap(_.toIntOption).getOrElse(1) // Retrieve the user getResp <- client(Request.get(URL.root.port(port) / "users" / userId.toString)) body <- getResp.body.asString } yield assertTrue( createResp.status == Status.Created, body.contains("Alice") ) }.provide(TestServer.default, Client.default, Scope.default) } ``` State persisting across requests: ```scala title="zio-http-example-testing/src/test/scala/example/testing/TestingStateAcrossRequests.scala" package example.testing object TestingStateAcrossRequests extends ZIOSpecDefault { def spec = test("counter persists across requests") { for { client <- ZIO.service[Client] port <- ZIO.serviceWithZIO[Server](_.port) counter <- Ref.make(0) _ <- TestServer.addRoute { Method.GET / "increment" -> handler { (_: Request) => counter.updateAndGet(_ + 1).map(n => Response.text(s"Count: $n")) } } // Multiple requests share the same counter resp1 <- client(Request.get(URL.root.port(port) / "increment")) body1 <- resp1.body.asString resp2 <- client(Request.get(URL.root.port(port) / "increment")) body2 <- resp2.body.asString } yield assertTrue( body1 == "Count: 1", body2 == "Count: 2" ) }.provide(TestServer.default, Client.default, Scope.default) } ``` Testing error paths: ```scala title="zio-http-example-testing/src/test/scala/example/testing/ErrorHandling.scala" package example.testing object ErrorHandling extends ZIOSpecDefault { def spec = test("returns 401 when authorization missing") { for { client <- ZIO.service[Client] port <- ZIO.serviceWithZIO[Server](_.port) _ <- TestServer.addRoute { Method.GET / "protected" -> handler { (req: Request) => if (req.headers.contains("Authorization")) ZIO.succeed(Response.ok) else ZIO.succeed( Response.status(Status.Unauthorized) .addHeader("WWW-Authenticate", "Bearer realm=\"api\"") ) } } // Without auth → 401 noAuthResp <- client(Request.get(URL.root.port(port) / "protected")) // With auth → 200 withAuthResp <- client( Request.get(URL.root.port(port) / "protected") .addHeader("Authorization", "Bearer token123") ) } yield assertTrue( noAuthResp.status == Status.Unauthorized, withAuthResp.status == Status.Ok ) }.provide(TestServer.default, Client.default, Scope.default) } ``` ([source](https://github.com/zio/zio-http/blob/main/zio-http-example-testing/src/test/scala/example/testing/ErrorHandling.scala)) ### Layer 4: WebSocket Testing with TestChannel (Special case) For bidirectional communication, `TestChannel` lets both client and server exchange messages through in-memory queues: ``` Client Handler ──[TestChannel]──> Server Handler (sends messages) (sends responses) ``` Test real-time notifications, chat messages, live data streams—all without actual network I/O. Both sides run concurrently, fully controllable. Practical example: ```scala title="zio-http-example-testing/src/test/scala/example/testing/TestingWebSocketCommunication.scala" package example.testing object TestingWebSocketCommunication extends ZIOSpecDefault { def spec = test("echo server echoes messages") { val echoServer: WebSocketApp[Any] = Handler.webSocket { channel => channel.receiveAll { case Read(WebSocketFrame.Text(message)) => channel.send(Read(WebSocketFrame.text(s"Echo: $message"))) case _ => ZIO.unit } } for { receivedFrame <- Promise.make[Throwable, WebSocketFrame] testClient: WebSocketApp[Any] = Handler.webSocket { channel => for { // Skip handshake complete event _ <- channel.receive _ <- channel.send(Read(WebSocketFrame.text("Hello"))) response <- channel.receive _ <- response match { case Read(frame) => receivedFrame.succeed(frame) case _ => receivedFrame.fail(new Exception("Expected ChannelEvent.Read")) } _ <- channel.shutdown } yield () } _ <- TestClient.installSocketApp(echoServer) _ <- ZIO.serviceWithZIO[Client](_.socket(testClient)) frame <- receivedFrame.await } yield assertTrue( frame match { case WebSocketFrame.Text(msg) => msg == "Echo: Hello" case _ => false } ) }.provide(TestClient.layer, Scope.default) } ``` ## See Also - [TestServer](./test-server.md) — Full reference for integration testing - [TestClient](./test-client.md) — Full reference for mocking HTTP dependencies - [TestChannel](./test-channel.md) — Full reference for WebSocket testing - [HttpTestAspect](./http-test-aspect.md) — Full reference for test aspects - [Running the Examples](./examples.md) — Runnable test examples and how to execute them - [Testing HTTP Applications](../../guides/testing-http-apps) — Comprehensive how-to guide with patterns --- ## Installation In this guide, we will learn how to get started with a new ZIO HTTP project. Before we dive in, we should make sure that we have the following on our computer: * JDK 17 or higher * sbt (scalaVersion >= 2.12) ## Teach Your Coding Agent Latest ZIO HTTP Knowledge The `zio-http-knowledge` skill teaches your coding agent to fetch live documentation from ziohttp.com before answering any ZIO HTTP question — so you always get accurate, up-to-date answers, not guesses from stale training data. **To install the skill:** ```bash npx skills add zio/zio-skills --skill zio-http-knowledge ``` **This skill covers:** - ZIO HTTP core, routing, middleware, streaming, and more - Fetches current docs from ziohttp.com on ZIO HTTP development questions ## Manual Installation To use ZIO HTTP, we should add the following dependencies in our project: ```scala libraryDependencies += "dev.zio" %% "zio-http" % "3.8.1" ``` ## Using g8 Template To set up a ZIO HTTP project using the provided g8 template we can run the following command on our terminal: ```shell sbt new zio/zio-http.g8 ``` This template includes the following plugins: * [sbt-native-packager](https://github.com/sbt/sbt-native-packager) * [scalafmt](https://github.com/scalameta/scalafmt) * [scalafix](https://github.com/scalacenter/scalafix) * [sbt-revolver](https://github.com/spray/sbt-revolver) These dependencies in the g8 template were added to enable an efficient development process. ### Hot-reload Changes (Watch Mode) [Sbt-revolver](https://github.com/spray/sbt-revolver) can watch application resources for change and automatically re-compile and then re-start the application under development. This provides a fast development-turnaround, the closest we can get to real hot-reloading. We can start our application from _sbt_ with the following command: ```shell ~reStart ``` Pressing enter will stop watching for changes, but not stop the application. We can use the following command to stop the application (shutdown hooks will not be executed): ``` reStop ``` In case we already have a _sbt_ server running, i.e. to provide our IDE with BSP information, we should use _sbtn_ instead of _sbt_ to run `~reStart`, this lets both _sbt_ sessions share one server. ### Formatting Source Code Scalafmt will automatically format all source code and assert that all team members use consistent formatting. ### Refactoring and Linting Scalafix will mainly be used as a linting tool during everyday development, for example by removing unused dependencies or reporting errors for disabled features. Additionally, it can simplify upgrades of Scala versions and dependencies, by executing predefined migration paths. ### SBT Native Packager The `sbt-native-packager` plugin can package the application in the most popular formats, for example, Docker images, RPM packages, or graalVM native images. --- ## HandlerAspect A `HandlerAspect` is a wrapper around `ProtocolStack` with the two following features: - It is a `ProtocolStack` that only works with `Request` and `Response` types. So it is suitable for writing middleware in the context of HTTP protocol. So it can almost be thought of (not the same) as a `ProtocolStack[Env, Request, Request, Response, Response]]`. - It is specialized to work with an output context `CtxOut` that can be passed through the middleware stack. This allows each layer to add its output context to the transformation process. So the `CtxOut` will be a tuple of all the output contexts that each layer in the stack has added. These output contexts are useful when we are writing middleware that needs to pass some information, which is the result of some computation based on the input request, to the handler that is at the end of the middleware stack. The diagram below illustrates how `HandlerAspect` works: ![HandlerAspect Diagram](handler-aspect.svg) Now, we are ready to see the definition of `HandlerAspect`: ```scala final case class HandlerAspect[-Env, +CtxOut]( protocol: ProtocolStack[Env, Request, (Request, CtxOut), Response, Response] ) extends Middleware[Env] { def apply[Env1 <: Env, Err](routes: Routes[Env1, Err]): Routes[Env1, Err] = ??? } ``` Like the `ProtocolStack`, the `HandlerAspect` is a stack of layers. When we compose two `HandlerAspect` using the `++` operator, we are composing handler aspects sequentially. So each layer in the stack corresponds to a separate transformation. Similar to the `ProtocolStack`, each layer in the `HandlerAspect` may also be stateful at the level of each transformation. So, for example, a layer that is timing request durations may capture the start time of the request in the incoming interceptor, and pass this state to the outgoing interceptor, which can then compute the duration. ## Creating a HandlerAspect The `HandlerAspect`'s companion object provides many methods to create a `HandlerAspect`. But in this section, we are going to introduce the most basic ones that are used as a building block to create a more complex `HandlerAspect`. The `HandlerAspect.identity` is the simplest `HandlerAspect` that does nothing. It is useful when you want to create a `HandlerAspect` that does not modify the request or response. After this simple `HandlerAspect`, let's dive into the `HandlerAspect.intercept*` constructors. Using these, we can create a `HandlerAspect` that can intercept the incoming request, outgoing response, or both. ## Built-in Handler Aspects ZIO HTTP offers a versatile set of built-in handler aspects, designed to enhance and customize the handling of HTTP requests and responses. These aspects can be easily integrated into our application to provide various functionalities. For the rest of this page, we will explore how to use them in our applications. ## Intercepting ### Intercepting the Incoming Requests The `HandlerAspect.interceptIncomingHandler` constructor takes a handler function and applies it to the incoming request. It is useful when we want to modify or access the request before it reaches the handler or the next layer in the stack. Let's see an example of how to use this constructor to create a handler aspect that checks the IP address of the incoming request and allows only the whitelisted IP addresses to access the server: ```scala val whitelistMiddleware: HandlerAspect[Any, Unit] = HandlerAspect.interceptIncomingHandler { val whitelist = Set("127.0.0.1", "0.0.0.0") Handler.fromFunctionZIO[Request] { request => request.headers.get("X-Real-IP") match { case Some(host) if whitelist.contains(host) => ZIO.succeed((request, ())) case _ => ZIO.fail(Response.forbidden("Your IP is banned from accessing the server.")) } } } ``` ### Intercepting the Outgoing Responses The `HandlerAspect.interceptOutgoingHandler` constructor takes a handler function and applies it to the outgoing response. It is useful when we want to modify or access the response before it reaches the client or the next layer in the stack. Let's work on creating a handler aspect that adds a custom header to the response: ```scala val addCustomHeader: HandlerAspect[Any, Unit] = HandlerAspect.interceptOutgoingHandler( Handler.fromFunction[Response](_.addHeader("X-Custom-Header", "Hello from Custom Middleware!")), ) ``` The `interceptOutgoingHandler` takes a handler function that receives a `Response` and returns a `Response`. This is simpler than the `interceptIncomingHandler` as it does not necessitate the output context to be passed along with the response. ### Intercepting Both Incoming Requests and Outgoing Responses The `HandlerAspect.interceptHandler` takes two handler functions, one for the incoming request and one for the outgoing response. In the following example, we are going to create a handler aspect that counts the number of incoming requests and outgoing responses and stores them in a `Ref` inside the ZIO environment: ```scala def inc(label: String) = for { counter <- ZIO.service[Ref[Map[String, Long]]] _ <- counter.update(_.updatedWith(label) { case Some(current) => Some(current + 1) case None => Some(1) }) } yield () val countRequests: Handler[Ref[Map[String, Long]], Nothing, Request, (Request, Unit)] = Handler.fromFunctionZIO[Request](request => inc("requests").as((request, ()))) val countResponses: Handler[Ref[Map[String, Long]], Nothing, Response, Response] = Handler.fromFunctionZIO[Response](response => inc("responses").as(response)) val counterMiddleware: HandlerAspect[Ref[Map[String, Long]], Unit] = HandlerAspect.interceptHandler(countRequests)(countResponses) ``` Then, we can write another handler aspect that is responsible for adding a route to get the statistics of the incoming requests and outgoing responses: ```scala val statsMiddleware: Middleware[Ref[Map[String, Long]]] = new Middleware[Ref[Map[String, Long]]] { override def apply[Env1 <: Ref[Map[String, Long]], Err](routes: Routes[Env1, Err]): Routes[Env1, Err] = routes ++ Routes( Method.GET / "stats" -> Handler.fromFunctionZIO[Request] { _ => ZIO.serviceWithZIO[Ref[Map[String, Long]]](_.get).map(stats => Response(body = Body.from(stats))) }, ) } ``` After attaching these two handler aspects to our `Routes`, we have to provide the initial state for the `Ref[Map[String, Long]]` to the whole application's environment: ```scala Server.serve(routes @@ counterMiddleware @@ statsMiddleware) .provide( Server.default, ZLayer.fromZIO(Ref.make(Map.empty[String, Long])) ) ``` ### Intercepting Statefully The `HandlerAspect.interceptHandlerStateful` constructor is like the `interceptHandler`, but it allows the incoming handler to have a state that can be passed to the next layer in the stack, and finally, that state can be accessed by the outgoing handler. Here is how it works: 1. The incoming handler receives a `Request` and produces a tuple of `State` and `(Request, CtxOut)`. 2. The state produced by the incoming handler is passed to the next layer in the stack. 3. The outgoing handler receives the `State` along with the `Response` as a tuple, i.e. `(State, Response)`, and produces a `Response`. So, we can pass some state from the incoming handler to the outgoing handler. In the following example, we are going to write an handler aspect that calculates the response time and includes it in the `X-Response-Time` header: ```scala val incomingTime: Handler[Any, Nothing, Request, (Long, (Request, Unit))] = Handler.fromFunctionZIO(request => ZIO.clockWith(_.currentTime(TimeUnit.MILLISECONDS)).map(t => (t, (request, ())))) val outgoingTime: Handler[Any, Nothing, (Long, Response), Response] = Handler.fromFunctionZIO { case (incomingTime, response) => ZIO .clockWith(_.currentTime(TimeUnit.MILLISECONDS).map(t => t - incomingTime)) .map(responseTime => response.addHeader("X-Response-Time", s"${responseTime}ms")) } val responseTime: HandlerAspect[Any, Unit] = HandlerAspect.interceptHandlerStateful(incomingTime)(outgoingTime) ``` By attaching this handler aspect to any route, we can see the response time in the `X-Response-Time` header: ```bash $ curl -X GET 'http://127.0.0.1:8080/hello' -i HTTP/1.1 200 OK content-type: text/plain X-Response-Time: 100ms content-length: 12 Hello World!⏎ ``` ### Intercepting Statefully (Patching Responses) Sometimes we want to apply a series of transformations to the outgoing response. We can use the `HandlerAspect.interceptPatch` and `HandlerAspect.interceptPatchZIO` to achieve this. A `Response.Patch` is a data type that represents a function (or series of functions) that can be applied to a response and return a new response. The `HanlderAspect.interceptPatch*` uses this data type to transform the response. The `HandlerApect.interceptPatch` takes two groups of arguments: 1. **Intercepting the Incoming Request**: The first one is a function that takes the incoming `Request` and produces a `State`. This state is passed through the handler aspect stack and then can be accessed through the interception phase of the outgoing response. 2. **Intercepting the Outgoing Response**: The second one is a function that takes a tuple of `Response` and `State` and returns a `Response.Patch` that will be applied to the outgoing response. Let's try to rewrite the previous example using the `HandlerAspect.interceptPatch`: ```scala val incomingTime: Request => ZIO[Any, Nothing, Long] = (_: Request) => ZIO.clockWith(_.currentTime(TimeUnit.MILLISECONDS)) val outgoingTime: (Response, Long) => ZIO[Any, Nothing, Response.Patch] = (_: Response, incomingTime: Long) => ZIO .clockWith(_.currentTime(TimeUnit.MILLISECONDS).map(t => t - incomingTime)) .map(responseTime => Response.Patch.addHeader("X-Response-Time", s"${responseTime}ms")) val responseTime: HandlerAspect[Any, Unit] = HandlerAspect.interceptPatchZIO(incomingTime)(outgoingTime) ``` ## Leveraging Output Context Ordinary Middlewares are intended to bracket a request's execution by intercepting the request, possibly modifying it or short-circuiting its execution, and then performing some post-processing on the response. However, we sometimes want to gather some contextual information about a request and pass it alongside to the request's handler. This can be achieved with the `HandlerAspect[Env, CtxOut]` type, which extends `Middleware[Env]`. The `HandlerAspect` middleware produces a value of type `CtxOut` on each request, which the routing DSL will accept just like a path component. If we take a look at the definition of `HandlerAspect`, we can see that it has two type parameters, `Env` and `CtxOut`. The `CtxOut` is the output context. When we don't need to pass any context to the output, we use `Unit` as the output context, otherwise, we can utilize any type as the output context. Before diving into a real-world example, let's try to understand the output context with simple examples. First, assume that we have an identity `HandlerAspect` that does nothing but passes an integer value to the output context: ```scala val intAspect: HandlerAspect[Any, Int] = HandlerAspect.identity.as(42) ``` To access this integer value in the handler, we need to define a handler that receives a tuple of `(Int, Request)`: ```scala val intRequestHandler: Handler[Int, Nothing, Request, Response] = Handler.fromFunctionZIO[Request] { (_: Request) => ZIO.serviceWith[Int] { n => Response.text(s"Received the $n value from the output context!") } } ``` If we attach the `intAspect` to this handler, we get back a handler that receives a `Request` and produces a `Response`: ```scala val handler: Handler[Any, Response, Request, Response] = intRequestHandler @@ intAspect ``` Another thing to note is that when we compose multiple `HandlerAspect`s with output context of non-`Unit` type, the output context of composed `HandlerAspect` will be a tuple of all the output contexts: ```scala val stringAspect: HandlerAspect[Any, String] = HandlerAspect.identity.as("Hello, World!") val intStringAspect: HandlerAspect[Any, (Int, String)] = intAspect ++ stringAspect ``` Correspondingly, to access the output context of this `HandlerAspect`, we need to have a handler that receives a tuple of `(Int, String, Request)`: ```scala val intStringRequestHandler: Handler[(Int, String), Nothing, Request, Response] = Handler.fromFunctionZIO[Request] { (req: Request) => ZIO.serviceWith[(Int, String)] { case (n, s) => Response.text(s"Received the $n and $s values from the output context!") } } ``` Finally, we can attach the `intStringAspect` to this handler: ```scala val handler: Handler[Any, Response, Request, Response] = intStringRequestHandler @@ (intAspect ++ stringAspect) ``` ### Session Example To look up a `Session`, we might use a `sessionMiddleware` with type `HandlerAspect[Env, Session]`: [//]: # (Invisible name declarations to get MDoc to compile) ```scala Routes( Method.GET / "user" / int("userId") -> handler { (userId: Int, request: Request) => withContext((session: Session) => UserRepository.getUser(session.organizationId, userId)) } ) @@ sessionMiddleware ``` The `HandlerAspect` companion object provides a number of helpful constructors for these middlewares. For this example, we would probably use `HandlerAspect.interceptHandler`, which wraps an incoming-request handler as well as one which performs any necessary post-processing on the outgoing response: ```scala val incomingHandler: Handler[Env, Response, Request, (Request, Session)] = ??? val outgoingHandler: Handler[Env, Nothing, Response, Response] = ??? HandlerAspect.interceptHandler(incomingHandler)(outgoingHandler) ``` Note the asymmetry in the type parameters of these two handlers: - In the incoming case, the handler emits a `Response` on the error-channel whenever the service cannot produce a `Session`, effectively short-circuiting the processing of this request. - The outgoing handler, by contrast, has `Nothing` as its `Err` type, meaning that it **cannot** fail and must always produce a `Response` on the success channel. ### Custom Authentication Example Now, let's see a real-world example where we can leverage the output context. In the following example, we are going to write an authentication handler aspect that checks the JWT token in the incoming request and passes the user information to the handler: ```scala // Secret Authentication key val SECRET_KEY = "secretKey" def jwtDecode(token: String, key: String): Try[JwtClaim] = Jwt.decode(token, key, Seq(JwtAlgorithm.HS512)) val bearerAuthWithContext: HandlerAspect[Any, String] = HandlerAspect.interceptIncomingHandler(Handler.fromFunctionZIO[Request] { request => request.header(Header.Authorization) match { case Some(Header.Authorization.Bearer(token)) => ZIO .fromTry(jwtDecode(token.value.asString, SECRET_KEY)) .orElseFail(Response.badRequest("Invalid or expired token!")) .flatMap(claim => ZIO.fromOption(claim.subject).orElseFail(Response.badRequest("Missing subject claim!"))) .map(u => (request, u)) case _ => ZIO.fail(Response.unauthorized.addHeaders(Headers(Header.WWWAuthenticate.Bearer(realm = "Access")))) } }) ``` Now, let's define the `/profile/me` route that requires authentication output context: ```scala val profileRoute: Route[Any, Response] = Method.GET / "profile" / "me" -> Handler.fromFunctionZIO[Request] { (_: Request) => ZIO.serviceWith[String](name => Response.text(s"Welcome $name!")) } @@ bearerAuthWithContext ``` That's it! Now, in the handler of the `/profile/me` route, we have the username that is extracted from the JWT token inside the authentication handler aspect and passed to it. The following code snippet is the complete example. Using the login route, we can get the JWT token and use it to access the protected `/profile/me` route: ```scala title="zio-http-example/src/main/scala/example/AuthenticationServer.scala" //> using dep "dev.zio::zio-http:3.4.0" //> using dep "com.github.jwt-scala::jwt-core:10.0.4" package example /** * This is an example to demonstrate bearer Authentication middleware. The * Server has 2 routes. The first one is for login, Upon a successful login, it * will return a jwt token for accessing protected routes. The second route is a * protected route that is accessible only if the request has a valid jwt token. * AuthenticationClient example can be used to makes requests to this server. */ object AuthenticationServer extends ZIOAppDefault { implicit val clock: Clock = Clock.systemUTC // Secret Authentication key val SECRET_KEY = "secretKey" def jwtEncode(username: String, key: String): String = Jwt.encode(JwtClaim(subject = Some(username)).issuedNow.expiresIn(300), key, JwtAlgorithm.HS512) def jwtDecode(token: String, key: String): Try[JwtClaim] = Jwt.decode(token, key, Seq(JwtAlgorithm.HS512)) val bearerAuthWithContext: HandlerAspect[Any, String] = HandlerAspect.interceptIncomingHandler(Handler.fromFunctionZIO[Request] { request => request.header(Header.Authorization) match { case Some(Header.Authorization.Bearer(token)) => ZIO .fromTry(jwtDecode(token.value.asString, SECRET_KEY)) .orElseFail(Response.badRequest("Invalid or expired token!")) .flatMap(claim => ZIO.fromOption(claim.subject).orElseFail(Response.badRequest("Missing subject claim!"))) .map(u => (request, u)) case _ => ZIO.fail(Response.unauthorized.addHeaders(Headers(Header.WWWAuthenticate.Bearer(realm = "Access")))) } }) def routes: Routes[Any, Response] = Routes( // A route that is accessible only via a jwt token Method.GET / "profile" / "me" -> handler { (_: Request) => ZIO.serviceWith[String](name => Response.text(s"Welcome $name!")) } @@ bearerAuthWithContext, // A login route that is successful only if the password is the reverse of the username Method.GET / "login" -> handler { (request: Request) => val form = request.body.asMultipartForm.orElseFail(Response.badRequest) for { username <- form .map(_.get("username")) .flatMap(ff => ZIO.fromOption(ff).orElseFail(Response.badRequest("Missing username field!"))) .flatMap(ff => ZIO.fromOption(ff.stringValue).orElseFail(Response.badRequest("Missing username value!"))) password <- form .map(_.get("password")) .flatMap(ff => ZIO.fromOption(ff).orElseFail(Response.badRequest("Missing password field!"))) .flatMap(ff => ZIO.fromOption(ff.stringValue).orElseFail(Response.badRequest("Missing password value!"))) } yield if (password.reverse.hashCode == username.hashCode) Response.text(jwtEncode(username, SECRET_KEY)) else Response.unauthorized("Invalid username or password.") }, ) @@ Middleware.debug override val run = Server.serve(routes).provide(Server.default) } ``` After running the server, we can test it using the following client code: ```scala title="zio-http-example/src/main/scala/example/AuthenticationClient.scala" //> using dep "dev.zio::zio-http:3.4.0" package example object AuthenticationClient extends ZIOAppDefault { /** * This example is trying to access a protected route in AuthenticationServer * by first making a login request to obtain a jwt token and use it to access * a protected route. Run AuthenticationServer before running this example. */ val url = "http://localhost:8080" val loginUrl = URL.decode(s"${url}/login").toOption.get val greetUrl = URL.decode(s"${url}/profile/me").toOption.get val program = for { client <- ZIO.service[Client] // Making a login request to obtain the jwt token. In this example the password should be the reverse string of username. token <- client .batched( Request .get(loginUrl) .withBody( Body.fromMultipartForm( Form( FormField.simpleField("username", "John"), FormField.simpleField("password", "nhoJ"), ), Boundary("boundary123"), ), ), ) .flatMap(_.body.asString) // Once the jwt token is procured, adding it as a Bearer token in Authorization header while accessing a protected route. response <- client.batched(Request.get(greetUrl).addHeader(Header.Authorization.Bearer(token))) body <- response.body.asString _ <- Console.printLine(body) } yield () override val run = program.provide(Client.default) } ``` ## Authentication Handler Aspects There are several built-in `HandlerAspect`s that can be used to implement authentication in ZIO HTTP: 1. **Basic Authentication**: The `basicAuth` and `basicAuthZIO` handler aspect can be used to implement basic authentication. 2. **Bearer Authentication**: The `bearerAuth` and `bearerAuthZIO` handler aspect can be used to implement bearer authentication. We have to provide a function that validates the bearer token. 3. **Custom Authentication**: The `customAuth` and `customAuthZIO` handler aspects can be used to implement custom authentication. We have to provide a function that validates the request. 4. **Custom Authentication providing**: The `customAuthProviding` and `customAuthProvidingZIO` handler aspects allow us to provide a value to the handler based on the authentication result. ### Basic Authentication Example ```scala title="zio-http-example/src/main/scala/example/BasicAuth.scala" //> using dep "dev.zio::zio-http:3.4.0" package example object BasicAuth extends ZIOAppDefault { // Http app that requires basic auth val user: Routes[Any, Response] = Routes( Method.GET / "user" / string("name") / "greet" -> handler { (name: String, _: Request) => Response.text(s"Welcome to the ZIO party! ${name}") }, ) // Add basic auth middleware val routes: Routes[Any, Response] = user @@ basicAuth("admin", "admin") val run = Server.serve(routes).provide(Server.default) } ``` ### Custom Authentication Providing Example ```scala title="zio-http-example/src/main/scala/example/middleware/CustomAuthProviding.scala" //> using dep "dev.zio::zio-http:3.4.0" package example.middleware object CustomAuthProviding extends ZIOAppDefault { final case class AuthContext(value: String) // Provides an AuthContext to the request handler val provideContext: HandlerAspect[Any, AuthContext] = HandlerAspect.customAuthProviding[AuthContext] { r => { r.headers.get(Header.Authorization).flatMap { case Header.Authorization.Basic(uname, password) if Secret(uname.reverse) == password => Some(AuthContext(uname)) case _ => None } } } // Multiple routes that require an AuthContext via withContext val secureRoutes: Routes[AuthContext, Response] = Routes( Method.GET / "a" -> handler((_: Request) => withContext((ctx: AuthContext) => Response.text(ctx.value))), Method.GET / "b" / int("id") -> handler((id: Int, _: Request) => withContext((ctx: AuthContext) => Response.text(s"for id: $id: ${ctx.value}")), ), Method.GET / "c" / string("name") -> handler((name: String, _: Request) => withContext((ctx: AuthContext) => Response.text(s"for name: $name: ${ctx.value}")), ), ) val app: Routes[Any, Response] = secureRoutes @@ provideContext val run = Server.serve(app).provide(Server.default) } ``` To the example, start the server and fire a curl request with an incorrect user/password combination: ```bash curl -i --user admin:wrong http://localhost:8080/user/admin/greet HTTP/1.1 401 Unauthorized www-authenticate: Basic content-length: 0 ``` We notice in the response that first `basicAuth` handler aspect responded `HTTP/1.1 401 Unauthorized` and then patch handler aspect attached a `X-Environment: Dev` header. ## Failing HandlerAspects We can abort the requests by specific response using `HandlerAspect.fail` and `HandlerAspect.failWith` aspects, so the downstream handlers will not be executed: ```scala myHandler @@ HandlerAspect.fail(Response.forbidden("Access Denied!")) myHandler @@ HandlerAspect .fail(Response.forbidden("Access Denied!")) .when(req => req.method == Method.DELETE) ``` ## Updating Requests and Responses Several aspects are useful for updating the requests and responses: | Description | HandlerAspect | |-------------------------|-------------------------------------------------------------------| | Update Request | `HandlerAspect.updateRequest`, `HandlerAspect.updateRequestZIO` | | Update Request's Method | `HandlerAspect.updateMethod` | | Update Request's Path | `HandlerAspect.updatePath` | | Update Request's URL | `HandlerAspect.updateURL` | | Update Response | `HandlerAspect.updateResponse`, `HandlerAspect.updateResponseZIO` | | Update Response Headers | `HandlerAspect.updateHeaders` | | Update Response Status | `HandlerAspect.status` | These aspects can be used to modify the request and response before they reach the handler or the client. They take a function that transforms the request or response and returns the updated request or response. Let's see an example: ```scala val dropTrailingSlash = HandlerAspect.updateURL(_.dropTrailingSlash) ``` ## Access Control HandlerAspects To allow and disallow access to an HTTP based on some conditions, we can use the `HandlerAspect.allow` and `HandlerAspect.allowZIO` aspects. ```scala val disallow: HandlerAspect[Any, Unit] = HandlerAspect.allow(_ => false) val allow: HandlerAspect[Any, Unit] = HandlerAspect.allow(_ => true) val whitelistAspect: HandlerAspect[Any, Unit] = { val whitelist = Set("127.0.0.1", "0.0.0.0") HandlerAspect.allow(r => r.headers.get("X-Real-IP") match { case Some(host) => whitelist.contains(host) case None => false }, ) } ``` ## Cookie Operations Several aspects are useful for adding, signing, and managing cookies: 1. `HandlerAspect.addCookie` and `HandlerAspect.addCookieZIO` to add cookies 2. `HandlerAspect.signCookies` to sign cookies 3. `HandlerAspect.flashScopeHandling` to manage the flash scope ## Conditional Application of HandlerAspects We can attach a handler aspect conditionally using `HandlerAspect#when`, `HandlerAspect#whenZIO`, and `HandlerAspect#whenHeader` methods. Wen also uses the following constructors to have conditional handler aspects: `HandlerAspect.when`, `HandlerAspect.whenZIO`, `HandlerAspect.whenHeader`, `HandlerAspect.whenResponse`, and `HandlerAspect.whenResponseZIO`. We have also some `if-then-else` style constructors to create conditional aspects like `HandlerAspect.ifHeaderThenElse`, `HandlerAspect.ifMethodThenElse`, `HandlerAspect.ifRequestThenElse`, and `HandlerAspect.ifRequestThenElseZIO`. ## Request Logging Handler Aspect The `requestLogging` handler aspect is a common aspect that logs incoming requests. It is useful for debugging and monitoring purposes. This aspect logs information such as request method, URL, status code, duration, response and request size by default. We can also configure it to log request and response bodies, request and response headers which are disabled by default: ```scala object HandlerAspect { def requestLogging( level: Status => LogLevel = (_: Status) => LogLevel.Info, loggedRequestHeaders: Set[Header.HeaderType] = Set.empty, loggedResponseHeaders: Set[Header.HeaderType] = Set.empty, logRequestBody: Boolean = false, logResponseBody: Boolean = false, requestCharset: Charset = StandardCharsets.UTF_8, responseCharset: Charset = StandardCharsets.UTF_8, ): HandlerAspect[Any, Unit] = ??? } ``` ## Running Effect Before/After Every Request The `runBefore` and `runAfter` aspects are useful for running an effect before and after every request. These aspects can be used to perform some side effects like logging, metrics and debugging, before and after every request. ## Redirect Handler Aspect There is another handler aspect called `HandlerAspect.redirect` which takes a `URL` and redirects requests to that URL. ## Trailing Slash Handler Aspect A trailing slash is the last forward-slash character at the end of some URLs. ZIO HTTP have two built-in aspect to handle trailing slashes: - The `HandlerAspect.redirectTrailingSlash` aspect is useful for redirecting requests with trailing slashes to the same URL without a trailing slash. This aspect is useful for SEO purposes and to avoid duplicate content issues. - The `HandlerAspect.dropTrailingSlash` aspect just drops the trailing slash from the request URL. ## Patching Response Handler Aspect The `HandlerAspect.patch` and `HandlerAspect.patchZIO` take a function from `Request` to `Response.Patch` and apply the patch to the response. Here is an example of a handler aspect that adds a custom header to the response if the request has a specific header: ```scala HandlerAspect.patch(request => if (request.hasHeader("X-Foo")) Response.Patch.addHeader("X-Bar", "Bar Value") else Response.Patch.empty ) ``` ## Debug Handler Aspect The `debug` handler aspect is a useful aspect for debugging requests and responses. It prints the response status code, request method and url, and the response time of each request to the console. ```scala val helloRoute = Method.GET / "hello" -> Handler.fromResponse(Response.text("Hello World!")) @@ HandlerAspect.debug ``` When we send a GET request to the `/hello` route, we can see the following output in the console: ```shell 200 GET /hello 14ms ``` ## Examples ### A Simple Middleware Example Let us consider a simple example using an out-of-the-box handler aspect called ```addHeader```. We will write an aspect that will attach a custom header to the response. We create an aspect that appends an additional header to the response indicating whether it is a Dev/Prod/Staging environment: ```scala title="zio-http-example/src/main/scala/example/HelloWorldWithMiddlewares.scala" //> using dep "dev.zio::zio-http:3.4.0" package example object HelloWorldWithMiddlewares extends ZIOAppDefault { val routes: Routes[Any, Response] = Routes( // this will return result instantly Method.GET / "text" -> handler(ZIO.succeed(Response.text("Hello World!"))), // this will return result after 5 seconds, so with 3 seconds timeout it will fail Method.GET / "long-running" -> handler(ZIO.succeed(Response.text("Hello World!")).delay(5 seconds)), ) val serverTime = Middleware.patchZIO(_ => for { currentMilliseconds <- Clock.currentTime(TimeUnit.MILLISECONDS) header = Response.Patch.addHeader("X-Time", currentMilliseconds.toString) } yield header, ) val middlewares = // print debug info about request and response Middleware.debug ++ // close connection if request takes more than 3 seconds Middleware.timeout(3 seconds) ++ // add static header Middleware.addHeader("X-Environment", "Dev") ++ // add dynamic header serverTime // Run it like any simple app val run = Server.serve(routes @@ middlewares).provide(Server.default) } ``` Fire a curl request, and we see an additional header added to the response indicating the "Dev" environment: ```bash curl -i http://localhost:8080/Bob HTTP/1.1 200 OK content-type: text/plain X-Environment: Dev content-length: 12 Hello Bob ``` --- ## Middleware A middleware helps in addressing common crosscutting concerns without duplicating boilerplate code. ## Definition Middleware can be conceptualized as a functional component that accepts a `Routes` and produces a new `Routes`. The defined trait, `Middleware`, is parameterized by a contravariant type `UpperEnv` which denotes it can access the environment of the `Routes`: ```scala trait Middleware[-UpperEnv] { self => def apply[Env1 <: UpperEnv, Err](routes: Routes[Env1, Err]): Routes[Env1, Err] } ``` This abstraction allows middleware to engage with the `Routes` environment, and also the ability to tweak existing routes or add/remove routes as needed. The diagram below illustrates how `Middleware` works: ![Middleware Diagram](middleware.svg) ## Usage The `@@` operator attaches middleware to routes and HTTP applications. The example below shows a middleware attached to an `Routes`: ```scala val app = Routes( Method.GET / string("name") -> handler { (name: String, req: Request) => Response.text(s"Hello $name") } ) val appWithMiddleware = app @@ Middleware.debug ``` Logically the code above translates to `Middleware.debug(app)`, which transforms the app using the middleware. ## Attaching Multiple Middlewares We can attach multiple middlewares by chaining them using more `@@` operators: ```scala val resultApp = routes @@ f1 @@ f2 @@ f3 ``` In the above code, when a new request comes in, it will first go through the `f3`'s incoming handler, then `f2`, then `f1`, and finally the `routes`, when the response is going out, it will go through the `f1`'s outgoing handler, then `f2`, then `f3`, and finally will be sent out. So **the order of the middlewares matters** and if we change the order of the middlewares, the application's behavior may change. ## Composing Middlewares Middleware can be combined using the `++` operator: ```scala routes @@ (f1 ++ f2 ++ f3) ``` The `f1 ++ f2 ++ f3` applies from left to right with `f1` first followed by others, like this: ```scala f3(f2(f1(routes))) ``` ## Motivation Before introducing middleware, let us understand why they are needed. ### The Problem: Violation of Separation of Concerns Principle Consider the following example where we have two endpoints: * **GET /users/\{id\}** - Get a single user by id * **GET /users** - Get all users ```scala val routes = Routes( Method.GET / "users" / int("id") -> handler { (id: Int, req: Request) => // core business logic dbService.lookupUsersById(id).map(Response.json(_.json)) }, Method.GET / "users" -> handler { // core business logic dbService.paginatedUsers(pageNum).map(Response.json(_.json)) } ) ``` As our application grows, we want to code the aspects like Basic Authentication, Request Logging, Response Logging, Timeout, and Retry for all our endpoints. For both of our example endpoints, our core business logic gets buried under boilerplate like this: ```scala (for { // validate user _ <- MyAuthService.doAuth(request) // log request _ <- logRequest(request) // core business logic user <- dbService.lookupUsersById(id).map(Response.json(_.json)) resp <- Response.json(user.toJson) // log response _ <- logResponse(resp) } yield resp) .timeout(2.seconds) .retryN(5) ``` Imagine repeating this for all our endpoints! So there are some problems with this approach: * **Violation of Separation of Concerns Principle**: Our current approach conflates business logic with cross-cutting concerns, such as timeouts, which violates the Separation of Concerns Principle. This coupling complicates the maintenance and understanding of our codebase. * **Code Duplication**: Replicating cross-cutting concerns across multiple routes results in unnecessary code duplication. For instance, if there are 100 routes, each requiring a timeout, we're forced to repeat the same logic 100 times. Consequently, any modification or upgrade to a shared concern, like altering the logging mechanism, necessitates making changes in numerous locations, significantly increasing the risk of errors and maintenance effort. * **Maintenance Nightmare**: With this approach, even a minor alteration in a cross-cutting concern demands updating every corresponding route. This not only escalates maintenance efforts but also complicates testing and debugging of core business logic. Consequently, the overall maintenance cost and complexity of the system are amplified. * **Readability Issues**— This can lead to a lot of boilerplate clogging our neatly written endpoints affecting readability. ### The solution: Middleware and Aspect-oriented Programming If we refer to Wikipedia for the definition of an "[Aspect](https://en.wikipedia.org/wiki/Aspect_(computer_programming))" we can glean the following points. * An aspect of a program is a feature linked to many other parts of the program (**_most common example, logging_**). * Tt is not related to the program's primary function (**_core business logic_**). * An aspect crosscuts the program's core concerns (**_for example logging code intertwined with core business logic_**). * Therefore, it can violate the principle of "separation of concerns" which tries to encapsulate unrelated functions. (**_Code duplication and maintenance nightmare_**) In short, aspect is a common concern required throughout the application, and its implementation could lead to repeated boilerplate code and violation of the principle of separation of concerns. There is a paradigm in the programming world called [aspect-oriented programming](https://en.wikipedia.org/wiki/Aspect-oriented_programming) that aims for modular handling of these common concerns in an application. Some examples of common "aspects" required throughout the application * **Logging**— Essential for tracking system behavior and troubleshooting * **Timeouts**— Used for preventing long-running code. * **Retries**— Vital for handling flakiness, particularly when accessing third-party APIs. * **Authentication**— Ensure users are authenticated before accessing REST resources, utilizing standard methods like basic authentication or more advanced approaches such as OAuth or single sign-on. This is where `Middleware` comes to the rescue. Using middlewares we can compose out-of-the-box middlewares (or our custom middlewares) to address the above-mentioned concerns using `++` and `@@` operators as shown below. ### The Solution: Middleware in ZIO-HTTP We cleaned up the code using middleware to address cross-cutting concerns such as authentication, request/response logging, and more. See how we can handle multiple cross-cutting concerns by neatly composing middlewares in a single place: ```scala // compose basic auth, request/response logging, timeouts middlewares val composedMiddlewares = Middleware.basicAuth("user","pw") ++ Middleware.debug ++ Middleware.timeout(5.seconds) ``` And then we can attach our composed bundle of middlewares to an Http using `@@` ```scala val routes = Routes( Method.GET / "users" / int("id") -> handler { (id: Int, req: Request) => // core business logic dbService.lookupUsersById(id).map(Response.json(_.json)) }, Method.GET / "users" -> handler { // core business logic dbService.paginatedUsers(pageNum).map(Response.json(_.json)) } ) @@ composedMiddlewares // attach composedMiddlewares to the routes using @@ ``` Observe how we gained the following benefits by using middlewares * **Readability**: de-cluttering business logic. * **Modularity**: we can manage aspects independently without making changes in 100 places. For example, * replacing the logging mechanism from logback to log4j2 will require a change in one place, the logging middleware. * replacing the authentication mechanism from OAuth to single sign-on will require changing the auth middleware * **Testability**: we can test our aspects independently. ## Building a Custom Middleware In most cases, we won't need a custom middleware. Instead, we have plenty of built-in middlewares that are ready to use. However, if we have a specific use case, we can create a custom middleware. To build a custom middleware, we have to implement the `Middleware` trait, which requires a single method `apply` to be implemented. The `apply` method accepts a `Routes` and returns a new `Routes`. For example, assume we want to replace every request path that starts with `/api` with `/v1/api`. We can create a custom middleware to achieve this: ```scala val urlRewrite: Middleware[Any] = new Middleware[Any] { override def apply[Env1 <: Any, Err](routes: Routes[Env1, Err]): Routes[Env1, Err] = routes.transform { handler => Handler.scoped[Env1] { Handler.fromFunctionZIO { request => handler( request.updateURL( url => if (url.path.startsWith(Path("/api"))) url.copy(path = Path("/v1") ++ url.path) else url, ), ) } } } } ``` The above implementation is just for demonstration purposes. In practice, we can use the built-in `Middleware.updatePath` to achieve the same functionality: ```scala val urlRewrite: Middleware[Any] = Middleware.updateURL(url => if (url.path.startsWith(Path("/api"))) url.copy(path = Path("/v1") ++ url.path) else url, ) ``` ## Built-in Middlewares In this section we are going to introduce built-in middlewares that are provided by ZIO HTTP. Please note that the `Middleware` object also inherits many other middlewares from the `HandlerAspect`, that we will introduce them on the [HandlerAspect](handler_aspect.md) page. ### Access Control Allow Origin (CORS) Middleware The CORS middleware is used to enable cross-origin resource sharing. It allows the server to specify who can access the resources on the server. The origin is a combination of the protocol, domain, and port of the client. By default, the server does not allow cross-origin requests. What this means is that if a client is hosted on a different domain (or different protocol and port), the server will reject the request. So, if the client is hosted on `http://localhost:3000` and the server is hosted on `http://localhost:8080`, the server will reject the request. This is where the client may want to create a preflight request to the server to ask for permission to access the resources. This is done by sending a preflight `OPTIONS` request to the server with the headers `Origin`, `Access-Control-Request-Method`, and `Access-Control-Request-Headers`. If the server determines that the request is allowed, it includes an `Access-Control-Allow-Origin` header in the response with a value that specifies which origins are permitted to access the resource. The same thing happens with the `Access-Control-Allow-Methods` and `Access-Control-Allow-Headers` headers. Now the client can decide whether to send the actual request or not. To create a CORS middleware, we can use the `Middleware.cors` constructor. It takes a configuration object of type `CorsConfig` that specifies the allowed origins, methods, headers, and so on. The `CorsConfig` object has the following fields: 1. **`allowedOrigin`**— A function that takes the origin of the client and returns allowed origins of type `Option[Header.AccessControlAllowOrigin]`. By default, the configuration object allows all origins (`*`). 2. **`allowedMethods`**— The `Access-Control-Allow-Methods` response header is used in response to a preflight request which includes the `Access-Control-Request-Method` to indicate which HTTP methods can be used during the actual request. By default, the configuration object allows all methods (`*`). 3. **`allowedHeaders`**— The `Access-Control-Allow-Headers` response header is used in response to a preflight request which includes the `Access-Control-Request-Headers` to indicate which HTTP headers can be used during the actual request. By default, the configuration object allows all headers (`*`). 4. **`allowCredentials`**— The `Access-Control-Allow-Credentials` header is sent in response to a preflight request which includes the `Access-Control-Request-Headers` to indicate whether the actual request can be made using credentials. By default, this configuration is set to `Allow`. 5. **`exposedHeaders`**— The `Access-Control-Expose-Headers` header is used in response to a preflight request to indicate which headers can be exposed as part of the response. By default, the configuration object exposes all headers (`*`). 6. **`maxAge`**— The `Access-Control-Max-Age` response header is used in response to a preflight request to indicate how long the results of a preflight request can be cached. By default, this configuration is set to `None`. In the following example, we are going to serve two HTTP apps. The first app is a backend that serves a JSON response that contains a message. The second app is a frontend that serves an HTML page with a script that fetches the JSON response from the backend. The frontend is hosted on `http://localhost:3000` and the backend is hosted on `http://localhost:8080`. If we try to fetch the JSON response from the frontend, the server will reject the request because the client is hosted on a different origin. To allow the frontend to access the backend, we need to create a CORS middleware that allows the origin `http://localhost:3001`. We can do this by creating a `CorsConfig` object with an `allowedOrigin` function that returns `Some(AccessControlAllowOrigin.Specific(origin))` if the origin is `http://localhost:3000`. We then attach the CORS middleware to the backend using the `@@` operator. ```scala title="zio-http-example/src/main/scala/example/HelloWorldWithCORS.scala" //> using dep "dev.zio::zio-http:3.4.0" package example object HelloWorldWithCORS extends ZIOAppDefault { val config: CorsConfig = CorsConfig( allowedOrigin = { case origin if origin == Origin.parse("http://localhost:3000").toOption.get => Some(AccessControlAllowOrigin.Specific(origin)) case _ => None }, ) val backend: Routes[Any, Response] = Routes( Method.GET / "json" -> handler(Response.json("""{"message": "Hello World!"}""")), ) @@ cors(config) val frontend: Routes[Any, Response] = Routes( Method.GET / PathCodec.empty -> handler( Response.html( html( p("Message: ", output()), script(""" |// This runs on http://localhost:3000 |fetch("http://localhost:8080/json") | .then((res) => res.json()) | .then((res) => document.querySelector("output").textContent = res.message); |""".stripMargin), ), ), ), ) val frontEndServer = Server.serve(frontend).provide(Server.defaultWithPort(3000)) val backendServer = Server.serve(backend).provide(Server.defaultWithPort(8080)) val run = frontEndServer.zipPar(backendServer) } ``` ### Metrics Middleware The `Middleware.metrics` middleware is used to collect metrics about the HTTP requests and responses that are processed by the server. The middleware collects the following metrics: * **`http_requests_total`**— The total number of HTTP requests that have been processed by the server, using the counter metric type. * **`http_request_duration_seconds`**— The duration of the HTTP requests in seconds, using the histogram metric type. * **`http_concurrent_requests_total`**— The total number of concurrent HTTP requests that are being processed by the server, using the gauge metric type. In the following example, we are going to serve two HTTP apps. One app is a backend that has some routes and the other app is a metrics app that serves the Prometheus metrics. We have attached the `Middleware.metrics` middleware to the backend using the `@@` operator. In this example we used the Prometheus connector, so we need to add the following dependencies to the `build.sbt` file: ```scala libraryDependencies ++= Seq( "dev.zio" %% "zio-metrics-connectors" % "2.3.1", "dev.zio" %% "zio-metrics-connectors-prometheus" % "2.3.1" ) ``` To integrate with other metrics systems, please refer to the [ZIO Metrics Connectors](https://zio.dev/zio-metrics-connectors/) documentation. ```scala title="zio-http-example/src/main/scala/example/HelloWorldWithMetrics.scala" //> using dep "dev.zio::zio-http:3.4.0" //> using dep "dev.zio::zio-metrics-connectors-prometheus:2.3.1" package example object HelloWorldWithMetrics extends ZIOAppDefault { val backend: Routes[Any, Response] = Routes( Method.GET / "json" -> handler((req: Request) => ZIO.succeed(Response.json("""{"message": "Hello World!"}""")) @@ Metric .counter("x_custom_header_total") .contramap[Any](_ => if (req.headers.contains("X-Custom-Header")) 1L else 0L), ), Method.GET / "forbidden" -> handler(ZIO.succeed(Response.forbidden)), ) @@ Middleware.metrics() val metrics: Routes[PrometheusPublisher, Response] = Routes( Method.GET / "metrics" -> handler(ZIO.serviceWithZIO[PrometheusPublisher](_.get.map(Response.text))), ) val run = Server .serve(backend ++ metrics) .provide( Server.default, // The prometheus reporting layer prometheus.prometheusLayer, prometheus.publisherLayer, // Interval for polling metrics ZLayer.succeed(MetricsConfig(1.seconds)), ) } ``` Another important thing to note is that the `metrics` middleware only attaches to the `Routes` or `Routes`, so if we want to track some custom metrics particular to a handler, we can use the `ZIO#@@` operator to attach a metric of type `ZIOAspect` to the ZIO effect that is returned by the handler. For example, if we want to track the number of requests that have a custom header `X-Custom-Header` in the `/json` route, we can attach a counter metric to the ZIO effect that is returned by the handler using the `@@` operator. ### Timeout Middleware The `Middleware.timeout` middleware is used to set a timeout for the HTTP requests that are processed by the server. If the request takes longer than the specified duration, the server will respond with request timeout status code `408`. The middleware takes a `Duration` parameter that specifies the timeout duration. ```scala routes @@ Middleware.timeout(5.seconds) ``` ### Log Annotation Middleware Using the `Middleware.logAnnotate*` middleware, we can add more annotations to the logging context. There are several variations of the `logAnnotate` middleware: * **`logAnnotate(key: => String, value: => String)`**— Adds a single log annotation with the specified key and value. * **`logAnnotate(logAnnotation: => LogAnnotation, logAnnotations: => LogAnnotation*)`**— Adds multiple log annotations with the specified log annotations. * **`logAnnotate(logAnnotations: => Set[LogAnnotation])`**— Adds multiple log annotations with the specified set of log annotations. * **`logAnnotate(fromRequest: Request => Set[LogAnnotation])`**— Adds log annotations derived from the request. * **`logAnnotateHeaders(headerName: String, headerNames: String*)`**— Adds log annotations with the names and values of the specified headers. * **`logAnnotateHeaders(header: Header.HeaderType, headers: Header.HeaderType*)`**— Adds log annotations with the names and values of the specified headers. Let's write a middleware that adds a correlation ID to the logging context, which is derived from the `X-Correlation-ID` header of the request. If the header is not present, we generate a random UUID as the correlation ID: ```scala val correlationId = Middleware.logAnnotate{ req => val correlationId = req.headers.get("X-Correlation-ID").getOrElse( Unsafe.unsafe { implicit unsafe => Runtime.default.unsafe.run(Random.nextUUID.map(_.toString)).getOrThrow() } ) Set(LogAnnotation("correlation-id", correlationId)) } ``` To see the correlation ID in the logs, we need to place the middleware after the request logging middleware: ```scala routes @@ Middleware.requestLogging() @@ correlationId ``` Now, if we call one of the routes with the `X-Correlation-ID` header, we should see the correlation ID in the logs: ```shell timestamp=2024-04-12T08:16:26.034894Z level=INFO thread=#zio-fiber-44 message="Http request served" location=example.HelloWorldWithLogging.backend file=HelloWorldWithLogging.scala line=20 method=GET correlation-id=34fab1bb-eeca-4b4f-975d-12f18e94f2e7 duration_ms=77 url=/json response_size=27 status_code=200 request_size=0 ``` ### Serving Static Files Middleware With the `Middleware.serveDirectory` and `Middleware.serveResources` middlewares, we can serve static files from a directory or resource directory in the classpath: ```scala title="zio-http-example/src/main/scala/example/StaticFiles.scala" //> using dep "dev.zio::zio-http:3.4.0" package example object StaticFiles extends ZIOAppDefault { /** * Creates an HTTP app that only serves static files from resources via * "/static". For paths other than the resources directory, see * [[zio.http.Middleware.serveDirectory]]. */ val routes = Routes.empty @@ Middleware.serveResources(Path.empty / "static") override def run = Server.serve(routes).provide(Server.default) } ``` ### Ensure Header Middleware The `Middleware.ensureHeader` middleware guarantees that a specific header is present in incoming requests. If the header already exists, the request passes through unchanged. If the header is missing, the middleware adds it with a provided default value. This middleware comes in two variants: 1. **Type-safe variant** - Uses ZIO HTTP's `Header.HeaderType` for compile-time type safety: ```scala mdoc:compile-only Middleware.ensureHeader(Header.ContentType)(MediaType.application.json) ``` 2. **String-based variant** - Uses header name and value as strings for flexibility: ```scala mdoc:compile-only Middleware.ensureHeader("X-Request-ID")("default-request-id") ``` A common use case for this middleware is to ensure that every incoming request contains a unique correlation ID, which is essential for distributed tracing in microservices architectures: ```scala val routes = Routes( Method.GET / "api" / trailing -> handler { Response.text("API response") } ) @@ Middleware.ensureHeader("X-Request-ID")(UUID.randomUUID().toString) ``` ### Forwarding Headers Middleware The `Middleware.forwardHeaders` middleware is used to forward headers from the incoming request to the outgoing request when using the ZIO HTTP Client. This is useful when we want to forward headers like `Authorization`, `X-Request-ID`, and so on. It takes a set of header names to be forwarded: ```scala routes @@ Middleware.forwardHeaders(Set("X-Request-ID", "Authorization")) ``` This middleware extracts the specified headers from the incoming request and stores them in the `RequestStore`. When we make an outgoing request using the ZIO HTTP Client, the `ZClientAspect.forwardHeaders` retrieves the headers from the [`RequestStore`](../contextual/request-store.md) and adds them to the outgoing request. To see the full flow, please refer to the [`ZClientAspect.forwardHeaders` documentation](../client.md#forwarding-headers). --- ## ProtocolStack :::note The `ProtocolStack` is a low-level data type typically utilized in other higher abstractions such as `HandlerAspect` and `Middleware` for building middlewares. If you intend to write middleware, it is advisable in most cases to utilize these higher abstractions, as they simplify the process of middleware creation. The `ProtocolStack` is a more advanced concept that provides fine-grained control over the types of inputs and outputs at each layer of the middleware stack, instead of common `Request` and `Response` types. Learning about `ProtocolStack` is recommended as it can be beneficial for understanding the inner workings of how middleware is constructed. ::: `ProtocolStack` is a data type that represents a stack of one or more protocol layers. Each layer in the stack is a function that transforms the incoming and outgoing values of some handler. We can think of a `ProtocolStack` as a function (or a composition of functions) that takes a handler and returns a new handler. The new handler is the result of applying each layer in the stack to the handler: ```scala trait ProtocolStack[-R, -II, +IO, -OI, +OO] { def apply[Env <: R, Err >: OO, IncomingOut >: IO, OutgoingIn <: OI]( handler: Handler[Env, Err, IncomingOut, OutgoingIn], ): Handler[Env, Err, II, OO] } ``` The `ProtocolStack` data type has 5 type parameters, one for the ZIO environment, and four for the incoming and outgoing input and output types of the protocol stack: - **Incoming Input**: This refers to data coming into the middleware from the client's HTTP request or the previous middleware in the chain. It could include information such as headers, cookies, query parameters, and the request body. - **Incoming Output**: This refers to the data leaving the middleware and heading towards the server or the next middleware in the chain. This could include modified request data or additional metadata added by the middleware. - **Outgoing Input**: This refers to data coming into the middleware from the handler or the previous middleware in the chain. It typically includes the HTTP response from the server, including headers, status codes, and the response body. - **Outgoing Output**: This refers to data leaving the middleware and heading back to the client. It could include modified response data, additional headers, or any other transformations applied by the middleware. A `ProtocolStack` can be created by combining multiple middleware functions using the `++` operator. Using the `++` operator, we can stack multiple middleware functions on top of each other to create a composite middleware that applies each middleware in the order they are stacked. The diagram below illustrates how `ProtocolStack` works: ![ProtocolStack Diagram](protocol-stack.svg) Here is the flow of data through the `ProtocolStack`: 1. The incoming input `II` is transformed by the first layer of the protocol stack to produce the incoming output `IO`. 2. The incoming output `IO` is passed to the next layer of the protocol stack (if exists) to produce a new incoming output. This process continues until all layers have been applied. 3. The incoming output `IO` is passed to the handler, which is the last layer where the actual processing of the request takes place. The handler processes the incoming output and produces the outgoing input `OI`. 4. The outgoing input `OI` is passed to the last layer of the protocol stack to produce the outgoing output `OO`. 5. The outgoing output `OO` is passed to the previous layer of the protocol stack (if exists) to produce a new outgoing output. This process continues until all layers have been applied. 6. The outgoing output `OO` is returned as the final result of the protocol stack. ![Multiple ProtocolStack Diagram](multiple-protocol-stack.svg) ## Creating a ProtocolStack There are several ways to create a `ProtocolStack`. One simple way is to start with an `identity` stack, which is a protocol stack that does nothing and simply passes the inputs to the outputs unchanged. Then, we can modify it by mapping over the inputs and outputs to apply transformations: ```scala type Request = String type Response = String val identity: ProtocolStack[Any, Request, Request, Response, Response] = ProtocolStack.identity[Request, Response] ``` Assume we have a handler that takes a request and reverses it to create a response: ```scala val uppercase: Handler[Any, Nothing, Request, Response] = Handler.fromFunction[Request](_.toUpperCase) ``` If we apply the `uppercase` handler to the `identity` stack, it will simply return the same handler without any modifications: ```scala val handler: Handler[Any, Response, Request, Response] = identity(uppercase) ``` The behavior of the `handler` remains the same. Let's test it: ```scala Unsafe.unsafe{ implicit unsafe => Runtime.default.unsafe.run( ZIO.scoped(handler("Hello World!")) ) } // res0: Exit[Response, Response] = Success(value = "HELLO WORLD!") ``` The output should be `HELLO WORLD!`, which is the result of applying the `uppercase` handler to the `identity` stack. The `ProtocolStack` has two main methods for transforming the incoming and outgoing values: `mapIncoming` and `mapOutgoing`. Using these methods, we can apply transformations to the incoming and outgoing values of the protocol stack. Let's create a new protocol stack that trims the incoming request, calculates the length of the outgoing response, and returns a tuple of the response and its length: ```scala val trimAndLength: ProtocolStack[Any, Request, Response, Response, (Response, Int)] = identity.mapIncoming(_.trim).mapOutgoing(r => (r, r.length)) ``` Now, let's apply the `uppercase` handler to the `trimAndLength` stack: ```scala val newHandler: Handler[Any, (Response, Int), Request, (Response, Int)] = trimAndLength(uppercase) Unsafe.unsafe { implicit unsafe => Runtime.default.unsafe.run( ZIO.scoped(newHandler("Hello World! ")), ) } ``` The output should be `(HELLO WORLD!, 12)`, which is the result of applying the `uppercase` handler to the `trimAndLength` stack. Please note that the `ProtocolStack` also has `interceptIncomingHandler` and `interceptOutgoingHandler` constructors that allow us to create a `ProtocolStack` by intercepting the incoming and outgoing handlers and applying transformations to them: ```scala val trim: ProtocolStack[Any, Request, Request, Response, Response] = ProtocolStack.interceptIncomingHandler(Handler.fromFunction[String](_.trim)) val length: ProtocolStack[Any, Request, Request, Response, (Response, Int)] = ProtocolStack.interceptOutgoingHandler(Handler.fromFunction[String](r => (r, r.length))) ``` Then we can combine them using the `++` operator: ```scala val anotherTrimAndLength: ProtocolStack[Any, Request, Request, Response, (Response, Int)] = length ++ trim // anotherTrimAndLength: ProtocolStack[Any, Request, Request, Response, (Response, Int)] = Concat( // left = Outgoing(handler = zio.http.Handler$FromFunction$$anon$16@ad109f0), // right = Incoming(handler = zio.http.Handler$FromFunction$$anon$16@556d049e) // ) ``` Now, let's apply the `uppercase` handler to the `anotherTrimAndLength` stack: ```scala Unsafe.unsafe { implicit unsafe => Runtime.default.unsafe.run( ZIO.scoped(anotherTrimAndLength(uppercase).apply("Hello World!")), ) } // res2: Exit[(Response, Int), (Response, Int)] = Success( // value = ("HELLO WORLD!", 12) // ) ``` We should get the same output as before: `(HELLO WORLD!, 12)`. When we want to apply a transformation to both the incoming and outgoing values, there is a very simple way to do it using the `interceptHandler` constructor. It takes two handlers, one for transforming the incoming input and one for transforming the outgoing input: ```scala val an: ProtocolStack[Any, Response, Response, Response, (Response, RuntimeFlags)] = ProtocolStack.interceptHandler(Handler.fromFunction[String](_.trim))( Handler.fromFunction[String](r => (r, r.length)), ) ``` ## Stateful ProtocolStacks In some cases, we may need to maintain some state along with the protocol stack. We can achieve such stateful behavior by using the `interceptHandlerStateful` constructor: ```scala object ProtocolStack { def interceptHandlerStateful[Env, State, II, IO, OI, OO]( incomingInputHandler: Handler[Env, OO, II, (State, IO)], )( outgoingOutputHandler: Handler[Env, Nothing, (State, OI), OO], ): ProtocolStack[Env, II, IO, OI, OO] = ??? } ``` The `interceptHandlerStateful` constructor takes two handlers: - **Incoming Input Handler**— Takes the incoming input of type `II` and returns the state along with the incoming output of type `(State, IO)`. - **Outgoing Input Handler**— Takes the state and the outgoing input of type `(State, OI)`, and returns the outgoing output of type `OO`. For example, assume we want to design a middleware to measure the total response time of the server. To achieve this, we should store the start time when the request enters the incoming input handler, and then access this state in the outgoing input handler to calculate the response time. Let's create a protocol stack that measures the response time of the server: ```scala val incomingInputHandler: Handler[Any, Nothing, String, (Long, String)] = Handler.fromFunctionZIO((in: String) => ZIO.clockWith(_.currentTime(TimeUnit.MILLISECONDS)).map(t => (t, in))) val outgoingInputHandler: Handler[Any, Nothing, (Long, String), (String, Long)] = Handler.fromFunctionZIO { case (startedTime: Long, in: String) => ZIO.clockWith(_.currentTime(TimeUnit.MILLISECONDS).map(t => (in, t - startedTime))) } val responseTime: ProtocolStack[Any, String, String, String, (String, Long)] = ProtocolStack.interceptHandlerStateful(incomingInputHandler)(outgoingInputHandler) ``` Finally, let's have a handler that converts the input to uppercase and takes some random time to process the request: ```scala val handler: Handler[Any, Nothing, String, String] = Handler.identity.mapZIO { (o: String) => ZIO.randomWith(_.nextLongBetween(0, 3000).flatMap(x => ZIO.sleep(Duration.fromMillis(x)))) *> ZIO.succeed( o.toUpperCase, ) } ``` Now, we are ready to test the `responseTime` protocol stack by applying the `handler` to it: ```scala Unsafe.unsafe { implicit unsafe => Runtime.default.unsafe.run( ZIO.scoped(responseTime(handler).apply("Hello, World!").debug("Response along with its latency")), ) } // res3: Exit[(String, Long), (String, Long)] = Success( // value = ("HELLO, WORLD!", 2001L) // ) ``` In the output, we should see the response which is the input converted to uppercase, and the response time in milliseconds. ## Working with ZIO Environment The first type parameter of the `ProtocolStack` data type represents the ZIO environment. This allows us to obtain access to the services and resources available in the environment when defining the protocol stack, like logging, configuration, database access, etc. In the following example, we will create a protocol stack that keeps track of the number of requests received by the server by storing the global state (`Ref[Int]`) in the environment: ```scala title="zio-http-example/src/main/scala/example/middleware/CounterProtocolStackExample.scala" //> using dep "dev.zio::zio-http:3.4.0" package example.middleware object CounterProtocolStackExample extends ZIOAppDefault { val uppercaseHandler: Handler[Any, Nothing, String, String] = Handler.fromFunction[String](_.toUpperCase) def requestCounter[I, O]: ProtocolStack[Ref[Long], I, I, O, (Long, O)] = ProtocolStack.interceptHandlerStateful { Handler.fromFunctionZIO[I] { (incomingInput: I) => for { db <- ZIO.service[Ref[Long]] _ <- db.update(_ + 1) c <- db.get } yield (c, incomingInput) } }(Handler.identity) val handler: Handler[Ref[Long], (Long, String), String, (Long, String)] = requestCounter[String, String](uppercaseHandler) def app = for { _ <- handler("Hello!").debug _ <- handler("Hello, World!").debug _ <- handler("What is ZIO?").debug } yield () def run = app.provide(Scope.default, ZLayer.fromZIO(Ref.make(0L))) } ``` ## Conditional ProtocolStacks In some cases, we may want to apply a protocol stack conditionally based on some criteria. We can achieve this by using the `cond` and `condZIO` constructors inside the `ProtocolStack` companion object. They take a predicate function that determines which protocol stack to apply based on the incoming input: ```scala ``` ```scala def requestCounter[I, O]: ProtocolStack[Ref[Long], I, I, O, O] = ProtocolStack.interceptIncomingHandler { Handler.fromFunctionZIO[I] { (incomingInput: I) => ZIO.serviceWithZIO[Ref[Long]](_.update(_ + 1)).as(incomingInput) } } def getMethodRequestCounter: ProtocolStack[Ref[Long], Request, Request, Response, Response] = ProtocolStack .cond[Request](_.method.matches(Method.GET))( ifTrue = requestCounter[Request, Response], ifFalse = ProtocolStack.identity[Request, Response], ) ``` In the above example, we defined a protocol stack that only counts the number of requests for the `GET` method. The state will be stored in a `Ref[Long]` service in the ZIO environment. --- ## BinaryCodecs for Request/Response Bodies ZIO HTTP has built-in support for encoding and decoding request/response bodies. This is achieved using generating codecs for our custom data types powered by [ZIO Schema](https://zio.dev/zio-schema). ZIO Schema is a library for defining the schema for any custom data type, including case classes, sealed traits, and enumerations, other than the built-in types. It provides a way to derive codecs for these custom data types, for encoding and decoding data to/from JSON, Protobuf, Avro, and other formats. Having codecs for our custom data types allows us to easily serialize/deserialize data to/from request/response bodies in our HTTP applications. The `Body` data type in ZIO HTTP represents the body message of a request or a response. It has two main functionality for encoding and decoding request/response bodies, both of which require an implicit `BinaryCodec` for the corresponding data type: * **`Body#to[A]`** — It decodes the request body to a custom data of type `A` using the implicit `BinaryCodec` for `A`. * **`Body.from[A]`** — It encodes custom data of type `A` to a response body using the implicit `BinaryCodec` for `A`. ```scala trait Body { def to[A](implicit codec: BinaryCodec[A]): Task[A] = ??? } object Body { def from[A](a: A)(implicit codec: BinaryCodec[A]): Body = ??? } ``` To use these two methods, we need to have an implicit `BinaryCodec` for our custom data type, `A`. Let's assume we have a `Book` case class with `title`, `authors` fields: ```scala case class Book(title: String, authors: List[String]) ``` To create a `BinaryCodec[Book]` for our `Book` case class, we can implement the `BinaryCodec` interface: ```scala implicit val bookBinaryCodec = new BinaryCodec[Book] { override def encode(value: Book): Chunk[Byte] = ??? override def streamEncoder: ZPipeline[Any, Nothing, Book, Byte] = ??? override def decode(whole: Chunk[Byte]): Either[DecodeError, Book] = ??? override def streamDecoder: ZPipeline[Any, DecodeError, Byte, Book] = ??? } ``` Now, when we call `Body.from(Book("Zionomicon", List("John De Goes")))`, it will encode the `Book` case class to a response body using the implicit `BinaryCodec[Book]`. But, what happens if we add a new field to the `Book` case class, or change one of the existing fields? We would need to update the `BinaryCodec[Book]` implementation to reflect these changes. Also, if we want to support body response bodies with multiple book objects, we would need to implement a new codec for `List[Book]`. So, maintaining these codecs can be cumbersome and error-prone. ZIO Schema simplifies this process by providing a way to derive codecs for our custom data types. For each custom data type, `A`, if we write/derive a `Schema[A]` using ZIO Schema, then we can derive a `BinaryCodec[A]` for any format supported by ZIO Schema, including JSON, Protobuf, Avro, and Thrift. So, let's generate a `Schema[Book]` for our `Book` case class: ```scala object Book { implicit val schema: Schema[Book] = DeriveSchema.gen[Book] } ``` Based on what format we want, we can add one of the following codecs to our `build.sbt` file: ```scala libraryDependencies += "dev.zio" %% "zio-schema-json" % "1.8.5" libraryDependencies += "dev.zio" %% "zio-schema-protobuf" % "1.8.5" libraryDependencies += "dev.zio" %% "zio-schema-avro" % "1.8.5" libraryDependencies += "dev.zio" %% "zio-schema-thrift" % "1.8.5" ``` After adding the required codec's dependency, we can import the right binary codec inside the `zio.schema.codec` package: | Codecs | Schema Based BinaryCodec (`zio.schema.codec` package) | Output | |----------|--------------------------------------------------------------------|----------------| | JSON | `JsonCodec.schemaBasedBinaryCodec[A](implicit schema: Schema[A])` | BinaryCodec[A] | | Protobuf | `ProtobufCodec.protobufCodec[A](implicit schema: Schema[A])` | BinaryCodec[A] | | Avro | `AvroCodec.schemaBasedBinaryCodec[A](implicit schema: Schema[A])` | BinaryCodec[A] | | Thrift | `ThriftCodec.thriftBinaryCodec[A](implicit schema: Schema[A])` | BinaryCodec[A] | | MsgPack | `MessagePackCodec.messagePackCodec[A](implicit schema: Schema[A])` | BinaryCodec[A] | That is very simple! To have a `BinaryCodec` of type `A` we only need to derive a `Schema[A]` and then use an appropriate codec from the `zio.schema.codec` package. ## JSON Codec Example ### JSON Serialization of Response Body Assume want to write an HTTP API that returns a list of books in JSON format: ```scala title="zio-http-example/src/main/scala/example/codecs/ResponseBodyJsonSerializationExample.scala" //> using dep "dev.zio::zio-http:3.4.0" //> using dep "dev.zio::zio-schema:1.7.2" //> using dep "dev.zio::zio-schema-json:1.7.2" //> using dep "dev.zio::zio-schema-derivation:1.7.5" package example.codecs object ResponseBodyJsonSerializationExample extends ZIOAppDefault { case class Book(title: String, authors: List[String]) object Book { implicit val schema: Schema[Book] = DeriveSchema.gen } val book1 = Book("Programming in Scala", List("Martin Odersky", "Lex Spoon", "Bill Venners", "Frank Sommers")) val book2 = Book("Zionomicon", List("John A. De Goes", "Adam Fraser")) val book3 = Book("Effect-Oriented Programming", List("Bill Frasure", "Bruce Eckel", "James Ward")) val routes: Routes[Any, Nothing] = Routes( Method.GET / "users" -> handler(Response(body = Body.from(List(book1, book2, book3)))), ) def run = Server.serve(routes).provide(Server.default) } ``` ### JSON Deserialization of Request Body In the example below, we have an HTTP API that accepts a JSON request body containing a `Book` object and adds it to a list of books: ```scala title="zio-http-example/src/main/scala/example/codecs/RequestBodyJsonDeserializationExample.scala" //> using dep "dev.zio::zio-http:3.4.0" //> using dep "dev.zio::zio-schema:1.7.2" //> using dep "dev.zio::zio-schema-json:1.7.2" //> using dep "dev.zio::zio-schema-derivation:1.7.5" package example.codecs object RequestBodyJsonDeserializationExample extends ZIOAppDefault { case class Book(title: String, authors: List[String]) object Book { implicit val schema: Schema[Book] = DeriveSchema.gen } val routes: Routes[Ref[List[Book]], Nothing] = Routes( Method.POST / "books" -> handler { (req: Request) => for { book <- req.body.to[Book].catchAll(_ => ZIO.fail(Response.badRequest("unable to deserialize the request"))) books <- ZIO.service[Ref[List[Book]]] _ <- books.updateAndGet(_ :+ book) } yield Response.ok }, Method.GET / "books" -> handler { (_: Request) => ZIO .serviceWithZIO[Ref[List[Book]]](_.get) .map(books => Response(body = Body.from(books))) }, ) def run = Server.serve(routes).provide(Server.default, ZLayer.fromZIO(Ref.make(List.empty[Book]))) } ``` To send a POST request to the `/books` endpoint with a JSON body containing a `Book` object, we can use the following `curl` command: ```shell $ curl -X POST -d '{"title": "Zionomicon", "authors": ["John De Goes", "Adam Fraser"]}' http://localhost:8080/books ``` After sending the POST request, we can retrieve the list of books by sending a GET request to the `/books` endpoint: ```shell $ curl http://localhost:8080/books ``` --- ## Body `Body` is a domain to model content for `Request` and `Response`. The body can be a fixed chunk of bytes, a stream of bytes, or form data, or any type that can be encoded into such representations (such as textual data using some character encoding, the contents of files, JSON, etc.). ZIO HTTP uses Netty at its core and Netty handles content as `ByteBuf`. `Body` helps you decode and encode this content into simpler, easier-to-use data types while creating a `Request` or `Response`. ## Usages The `Body` is used on both the server and client side. ### Server-side On the server side, `ZIO-HTTP` models content in `Request` and `Response` as `Body` with `Body.empty` as the default value. To add content while creating a `Response` you can use the `Response` constructor: ```scala object HelloExample extends ZIOAppDefault { val routes: Routes[Any, Response] = Routes( Method.GET / "hello" -> handler { req: Request => for { name <- req.body.asString } yield Response(body = Body.fromString(s"Hello $name!")) }.sandbox, ) override val run = Server.serve(routes).provide(Server.default) } ``` ### Client-side On the client side, `ZIO-HTTP` models content in `Client` as `Body` with `Body.Empty` as the default value. To add content while making a request using ZIO HTTP you can use the `Client.batched` method: ```scala object HelloClientExample extends ZIOAppDefault { val routes: ZIO[Client, Throwable, Unit] = for { name <- Console.readLine("What is your name? ") resp <- Client.batched(Request.post("http://localhost:8080/hello", Body.fromString(name))) body <- resp.body.asString _ <- Console.printLine(s"Response: $body") } yield () def run = routes.provide(Client.default) } ``` In the above example, we are making a `POST` request to the `/hello` endpoint with a `Body` containing the name of the user. Then we read the response body as a `String` and printed it: ``` What is your name? John Response: Hello John! ``` ## Creating a Body ### Empty Body To create an empty body: ```scala val emptyBody: Body = Body.empty ``` ### From a String and CharSequence To create a `Body` that encodes a `String` or `CharSequence` we can use `Body.fromString` or `Body.fromCharSequence`: ```scala Body.fromString("any string", Charsets.Http) Body.fromCharSequence("any string", Charsets.Http) ``` ### From Array/Chunk of Bytes To create a `Body` that encodes a chunk of bytes you can use `Body.fromChunk`: ```scala val chunkHttpData: Body = Body.fromChunk(Chunk.fromArray("Some String".getBytes(Charsets.Http))) val byteArrayHttpData: Body = Body.fromArray("Some String".getBytes(Charsets.Http)) ``` ### From a Value with ZIO Schema Binary Codec We can construct a body from an arbitrary value using zio-schema's binary codec: ```scala object Body { def from[A](a: A)(implicit codec: BinaryCodec[A], trace: Trace): Body = fromChunk(codec.encode(a)) } ``` For example, if you have a case class Person: ```scala case class Person(name: String, age: Int) implicit val schema = DeriveSchema.gen[Person] val person = Person("John", 42) val body = Body.from(person) ``` In the above example, we used a JSON codec to encode the person object into a body. Similarly, we can use other codecs like Avro, Protobuf, etc. ### From JSON ZIO HTTP provides convenient methods for creating a `Body` from JSON data using either **zio-schema** or **zio-json** libraries. #### Using ZIO Schema To create a JSON body using zio-schema, use the `Body.json` method with an implicit `Schema`: ```scala case class Person(name: String, age: Int) implicit val schema: Schema[Person] = DeriveSchema.gen[Person] val person = Person("John", 42) val body = Body.json(person) ``` This method automatically: - Encodes the value to JSON using zio-schema's JSON codec - Sets the `Content-Type` header to `application/json` #### Using ZIO JSON Alternatively, you can create a JSON body using zio-json with the `Body.jsonCodec` method: ```scala case class Person(name: String, age: Int) implicit val encoder: JsonEncoder[Person] = DeriveJsonEncoder.gen[Person] val person = Person("John", 42) val body = Body.jsonCodec(person) ``` This method: - Encodes the value to JSON using zio-json's encoder - Sets the `Content-Type` header to `application/json` **When to use which?** - Use `Body.json` (zio-schema) when you're already using zio-schema in your project or need advanced schema features - Use `Body.jsonCodec` (zio-json) when you prefer zio-json's dedicated JSON library or need fine-grained control over JSON encoding ### From ZIO Streams There are several ways to create a `Body` from a ZIO Stream: #### From Stream of Bytes To create a `Body` that encodes a stream of bytes, we can utilize the `Body.fromStream` and `Body.fromStreamChunked` constructors: ```scala object Body { def fromStream( stream: ZStream[Any, Throwable, Byte], contentLength: Long ): Body = ??? def fromStreamChunked( stream: ZStream[Any, Throwable, Byte] ): Body = ??? } ``` If we know the content length of the stream, we can use `Body.fromStream`. It will set the `content-length` header in the response to the given value: ```scala val chunk = Chunk.fromArray("Some String".getBytes(Charsets.Http)) val streamHttpData1: Body = Body.fromStream(ZStream.fromChunk(chunk), contentLength = chunk.length) ``` Otherwise, we can use `Body.fromStreamChunked`, which is useful for streams with an unknown content length. Assume we have a service that generates a response to a request in chunks; we can stream the response to the client while we don't know the exact length of the response. Therefore, the `transfer-encoding` header will be set to `chunked` in the response: #### From Stream of Values with ZIO Schema Binary Codec To create a `Body` that encodes a stream of values of type `A`, we can use `Body.fromStream` with a `BinaryCodec`: ```scala object Body { def fromStream[A](stream: ZStream[Any, Throwable, A])(implicit codec: BinaryCodec[A], trace: Trace): Body = ??? } ``` Let's create a `Body` from a stream of `Person`: ```scala case class Person(name: String, age: Int) implicit val schema = DeriveSchema.gen[Person] val persons: ZStream[Any, Nothing, Person] = ZStream.fromChunk(Chunk(Person("John", 42), Person("Jane", 40))) val body = Body.fromStream(persons) ``` The header `transfer-encoding` will be set to `chunked` in the response. #### From Stream of CharSequence To create a `Body` that encodes a stream of `CharSequence`, we can use `Body.fromCharSequenceStream` and `Body.fromCharSequenceStreamChunked` constructors. If we know the content length of the stream, we can use `Body.fromCharSequenceStream`, which will set the `content-length` header in the response to the given value. Otherwise, we can use `Body.fromCharSequenceStreamChunked`, which is useful for streams with an unknown content length. In this case, the `transfer-encoding` header will be set to `chunked` in the response. ### From a File To create an `Body` that encodes a `File` we can use `Body.fromFile`: ```scala val fileHttpData: ZIO[Any, Nothing, Body] = Body.fromFile(new java.io.File(getClass.getResource("/fileName.txt").getPath)) // java.lang.NullPointerException: Cannot invoke "java.net.URL.getPath()" because the return value of "java.lang.Class.getResource(String)" is null // at repl.MdocSession$MdocApp$$anonfun$31.apply$mcV$sp(body.md:225) // at repl.MdocSession$MdocApp$$anonfun$31.apply(body.md:223) // at repl.MdocSession$MdocApp$$anonfun$31.apply(body.md:223) ``` ### From WebSocketApp Any `WebSocketApp[Any]` can be converted to a `Body` using `Body.fromWebSocketApp`: ```scala object Body { def fromSocketApp(app: WebSocketApp[Any]): WebsocketBody = ??? } ``` ### From a Multipart Form Multipart form data is a method for encoding form data within an HTTP request. It allows for the transmission of multiple types of data, including text, files, and binary data, in a single request. This makes it ideal for scenarios where form submissions require complex data structures, such as file uploads or rich form inputs. #### Structure of a Multipart Form A multipart form consists of multiple parts, each representing a different field or file to be transmitted. These parts are separated by a unique boundary string. Each part typically includes headers specifying metadata about the data being transmitted, such as content type and content disposition, followed by the actual data. In ZIO HTTP, the `Form` data type is used to represent a form that can be either multipart or URL-encoded. It is a wrapper around `Chunk[FormField]`. #### Creating Response Body from Multipart Form The `Body.fromMultipartForm` is used to create a `Body` from a multipart form: ```scala object Body { def fromMultipartForm(form: Form, specificBoundary: Boundary): Body = ??? } ``` Let say we create a body from a multipart form: ```scala val body = Body.fromMultipartForm( Form( FormField.simpleField("key1", "value1"), FormField.binaryField( "file1", Chunk.fromArray("Hello, world!".getBytes), MediaType.text.`plain`, filename = Some("hello.txt"), ), FormField.binaryField( "file2", Chunk.fromArray("## Hello, world!".getBytes), MediaType.text.`markdown`, filename = Some("hello.md"), ), ), Boundary("boundary123"), ) ``` This will create a `Body` which can be rendered as: ``` --boundary123 Content-Disposition: form-data; name="key1" Content-Type: text/plain value1 --boundary123 Content-Disposition: form-data; name="file1"; filename="hello.txt" Content-Type: text/plain Hello, world! --boundary123 Content-Disposition: form-data; name="file2"; filename="hello.md" Content-Type: text/markdown ## Hello, world! --boundary123-- ``` :::note When utilizing MultipartForm for the response body, ensure the correct Content-Type header is included in the response, such as `Content-Type: multipart/; boundary=boundary123`. ::: :::note Please be aware that utilizing a multipart form for the response body is uncommon and may not be supported by all clients. If you intend to use this method, ensure comprehensive support across various browsers. ::: ### From a URL-encoded Form URL encoding is a technique used to convert data into a format that can be transmitted over the internet. This is necessary because URLs have certain restrictions on the characters they can contain. URL encoding replaces unsafe characters with a "%" followed by two hexadecimal digits. For example, a space is encoded as "%20", and special characters like "&" become "%26". A URL-encoded form consists of key-value pairs, where each pair represents a form field and its corresponding value. These pairs are concatenated together into a query string, separated by "&" symbols. For instance, consider a simple form with fields for "username" and "password". The URL-encoded form data looks like this: ``` username=john&password=secretpassword ``` Similar to `Body.fromMultipartForm`, the `Body.fromURLEncodedForm` is used to create a `Body` from a URL-encoded form: ```scala val body = Body.fromURLEncodedForm( Form( FormField.simpleField("username", "john"), FormField.simpleField("password", "secretpassword"), ) ) ``` This will create a `Body` which can be rendered as: ``` username=john&password=secretpassword ``` :::note URL encoding is primarily useful for encoding data in the query string of a URL or for encoding form data in HTTP requests. It is not typically used for the response body. ::: ## Body Operations ### Decoding Body Content as a String We can decode the content of the body into a `String` using the `Body#asString` method. It allows decoding with both default and custom charsets: ```scala val defaultCharsetString = body.asString val customCharsetString = body.asString(Charset.forName("UTF-8")) ``` These methods return a `Task` representing the decoded string content of the body. ### Decoding Body Content By providing a `BinaryCodec[A]` we can decode the body content to a value of type `A`: ```scala case class Person(name: String, age: Int) implicit val schema: Schema[Person] = DeriveSchema.gen[Person] val person = Person("John", 42) val body = Body.from(person) val decodedPerson = body.to[Person] ``` ### Decoding JSON Body Content ZIO HTTP provides convenient methods for decoding JSON body content using either **zio-schema** or **zio-json** libraries. #### Using ZIO Schema To decode a JSON body using zio-schema, use the `Body#asJson` method with an implicit `Schema`: ```scala case class Person(name: String, age: Int) implicit val schema: Schema[Person] = DeriveSchema.gen[Person] val jsonBody = Body.fromString("""{"name":"John","age":42}""") val decodedPerson: Task[Person] = jsonBody.asJson[Person] ``` This method: - Decodes the JSON body content using zio-schema's JSON codec - Returns a `Task[A]` that will fail if the JSON is invalid or doesn't match the schema #### Using ZIO JSON Alternatively, you can decode a JSON body using zio-json with the `Body#asJsonFromCodec` method: ```scala case class Person(name: String, age: Int) implicit val decoder: JsonDecoder[Person] = DeriveJsonDecoder.gen[Person] val jsonBody = Body.fromString("""{"name":"John","age":42}""") val decodedPerson: Task[Person] = jsonBody.asJsonFromCodec[Person] ``` This method: - Decodes the JSON body content using zio-json's decoder - Returns a `Task[A]` that will fail if the JSON is invalid or cannot be decoded **When to use which?** - Use `asJson` (zio-schema) when you're already using zio-schema in your project or need schema validation - Use `asJsonFromCodec` (zio-json) when you prefer zio-json's dedicated JSON library or need fine-grained control over JSON decoding **Round-trip Example:** Here's a complete example showing encoding and decoding with zio-schema: ```scala case class Person(name: String, age: Int) implicit val schema: Schema[Person] = DeriveSchema.gen[Person] val program = for { // Create a JSON body person <- ZIO.succeed(Person("Alice", 30)) body = Body.json(person) // Decode it back decoded <- body.asJson[Person] } yield decoded ``` ### Retrieving Raw Body Content We can access the content of the body as an array of bytes or a chunk of bytes. This is useful when dealing with binary data. Here's how you can do it: ```scala val byteArray: Task[Array[Byte]] = body.asArray val byteChunk: Task[Chunk[Byte]] = body.asChunk ``` These methods return the body content as an array of bytes or a ZIO chunk of bytes, respectively. ### Retrieving Body Content as a ZIO Stream We can access the content of the body as a ZIO stream of bytes: ```scala val byteStream = body.asStream // byteStream: ZStream[Any, Throwable, Byte] = zio.stream.ZStream@23642f19 ``` ### Decoding Multipart Form Data We can decode the content of the body as multipart form data: ```scala val multipartFormData: Task[Form] = body.asMultipartForm ``` ZIO HTTP supports streaming, allowing us to handle large files using **multipart/form-data**. By utilizing `Body#asMultipartFormStream`, which gives us a `Task` of `StreamingForm`. Using the `StreamingForm#fields` method we can access a stream of `FormField` representing the form's parts: ```scala for { form <- body.asMultipartFormStream count <- form.fields.flatMap { case FormField.Binary(name, data, contentType, transferEncoding, filename) => ??? case FormField.StreamingBinary(name, contentType, transferEncoding, filename, data) => ??? case FormField.Text(name, value, contentType, filename) => ??? case FormField.Simple(name, value) => ??? }.run(???) } yield () ``` Also, if there's sufficient memory available, we can execute `StreamingForm#collectAll` method gather all its parts into memory: ```scala val streamingForm: Task[StreamingForm] = body.asMultipartFormStream val collectedForm: Task[Form] = streamingForm.flatMap(_.collectAll) ``` --- ## Form Data The `Form` represents a collection of `FormFields` that can be a multipart or URL-encoded form: ```scala final case class Form(formData: Chunk[FormField]) ``` A `Form` is commonly used in request bodies for handling data from HTML forms and file uploads, although it can also be utilized in response bodies. ## Form Fields A `FormField` is a field within a `Form` and consists of a name, content type, type-specific content, and an optional filename. ```scala sealed trait FormField { def name: String def contentType: MediaType def filename: Option[String] } ``` There are four types of `FormField`: Simple FormField, Text FormField, Binary FormField, and StreamingBinary FormField. ### Simple FormField Simple form fields are represented by the `Simple` case class. They consist of a simple key-value pair containing a name and a value (String). Unlike Binary and Text, they do not contain additional metadata such as content type or filename: ```scala final case class Simple(name: String, value: String) extends FormField { override val contentType: MediaType = MediaType.text.plain override val filename: Option[String] = None } ``` To create a simple form field, we can use `FormField.simpleField` constructor: ```scala val simpleFormField = FormField.simpleField("name", "value") ``` Instances of `FormField.Simple` are commonly used for transmitting simple textual data where additional metadata is not required, such as form fields in HTML forms. ### Text FormField Text form fields are represented by the `Text` case class. They contain textual data (String) along with metadata such as the content type and optionally the filename: ```scala final case class Text( name: String, value: String, contentType: MediaType, filename: Option[String] = None, ) extends FormField ``` To create a text form field, we can use `FormField.textField` constructor: ```scala val textFormField1 = FormField.textField("name", "value") val textFormField2 = FormField.textField("name", "value", MediaType.text.plain) ``` Instances of `FormField.Text` are used for transmitting simple textual data and textual files, such as text files, HTML files, and so on. ### Binary FormField Binary form fields are represented by the `FormField.Binary` case class. They contain binary data (`Chunk[Byte]`), along with metadata such as the content type (`MediaType`) and optionally the `Content-Transfer-Encoding` header field and filename: ```scala final case class Binary( name: String, data: Chunk[Byte], contentType: MediaType, transferEncoding: Option[ContentTransferEncoding] = None, filename: Option[String] = None, ) extends FormField ``` To create a binary form field, we can use `FormField.binaryField` constructor: ```scala val image = Chunk.fromArray(???) val binaryFormField = FormField.binaryField( name = "profile pic", data = image, mediaType = MediaType.image.jpeg, transferEncoding = Some(ContentTransferEncoding.Binary), filename = Some("profile.jpg") ) ``` :::note The data is not encoded in any way relative to the provided `transferEncoding`. It is the responsibility of the user to encode the `data` accordingly. ::: This form field is suitable for transmitting files or other binary data through HTTP requests. The data is typically encoded in a way that can be transmitted as text (e.g., Base64 encoding) and decoded on the receiving end. The transfer encoding can be one of the following: `SevenBit`, `EightBit`, `Binary`, `QuotedPrintable`, `Base64`, and `XToken`. ## Creating a Form The `Form`'s companion object offers several convenient methods for constructing form data, whether from individual form fields, key-value pairs, multipart bytes, query parameters, or URL-encoded data. We'll cover each of these methods and provide examples to illustrate their usage. ### Creating an Empty Form We can create an empty form using the empty method: ```scala val emptyForm = Form.empty ``` This creates an empty form with no fields. ### Creating a Form from Form Fields We can create a form by providing individual form fields using the `Form.apply` method. This method takes one or more FormField objects: ```scala val form = Form( FormField.Simple("name", "John"), FormField.Simple("age", "42"), ) ``` ### Creating a Form from Key-Value Pairs We can create a form from key-value pairs using the `Form.fromStrings` method: ```scala val formData = Form.fromStrings( "username" -> "johndoe", "password" -> "secret", ) ``` ### Decoding Raw Multipart Bytes into a Form We can create a form from multipart bytes using the `Form.fromMultipartBytes` method. This is useful when handling multipart form data received in HTTP requests. Assume we have received the following multipart data: ```scala val multipartBytes = s"""|--boundary123\r |Content-Disposition: form-data; name="field1"\r |\r |value1\r |--boundary123\r |Content-Disposition: form-data; name="field2"\r |\r |value2\r |--boundary123\r |Content-Disposition: form-data; name="file1"; filename="filename1.txt"\r |Content-Type: text/plain\r |\r |Contents of filename1.txt\r |--boundary123\r |Content-Disposition: form-data; name="file2"; filename="filename2.txt"\r |Content-Type: text/plain\r |\r |Contents of filename2.txt\r |--boundary123--\r\n""".stripMargin.getBytes(Charsets.Utf8) ``` We can decode it with the following code: ```scala val charset = Charsets.Utf8 val formTask: Task[Form] = Form.fromMultipartBytes(Chunk.fromArray(multipartBytes), charset, Some(Boundary("boundary123"))) val formData: Task[Chunk[FormField]] = formTask.map(_.formData) ``` ### Creating a Form from Query Parameters We can create a form from query parameters using the `Form.fromQueryParams` method: ```scala val queryParams: QueryParams = QueryParams( "name" -> "John", "age" -> "42" ) val form = Form.fromQueryParams(queryParams) ``` ### Creating a Form from URL-Encoded Data We can create a form from URL-encoded data using the `Form.fromURLEncoded` method: ```scala val encodedData: String = "username=johndoe&password=secret" // encodedData: String = "username=johndoe&password=secret" val formResult: Either[FormDecodingError, Form] = Form.fromURLEncoded(encodedData, Charsets.Utf8) // formResult: Either[FormDecodingError, Form] = Right( // value = Form( // formData = IndexedSeq( // Simple(name = "username", value = "johndoe"), // Simple(name = "password", value = "secret") // ) // ) // ) ``` ## Operations ### Appending Fields to a Form We can append fields to an existing form using the `+` or `append` operator: ```scala val form = Form( FormField.simpleField("username", "johndoe"), FormField.simpleField("password", "secretpassword"), ) + FormField.simpleField("age", "42") // form: Form = Form( // formData = IndexedSeq( // Simple(name = "username", value = "johndoe"), // Simple(name = "password", value = "secretpassword"), // Simple(name = "age", value = "42") // ) // ) ``` ### Accessing Fields in a Form We can access fields in a form using the `get` method, which returns an option containing the first field with the specified name: ```scala form.get("username") // res8: Option[FormField] = Some( // value = Simple(name = "username", value = "johndoe") // ) ``` This method allows us to retrieve a specific field from the form by its name. ### Encoding Forms We can encode forms using multipart encoding or URL encoding. #### Multipart Encoding The `Form#multipartBytes` method takes the boundary and encodes the form using multipart encoding and returns the multipart byte stream: ```scala val form: Form = ??? val multipartStream: ZStream[Any, Nothing, Byte] = form.multipartBytes(Boundary("boundary123")) ``` #### URL Encoding The `Form#urlEncoded` method encodes the form using URL encoding and returns the encoded string: ```scala form.urlEncoded // res10: String = "username=johndoe&password=secretpassword&age=42" form.urlEncoded(Charsets.Utf8) // res11: String = "username=johndoe&password=secretpassword&age=42" ``` ### Converting Form to Query Parameters The `toQueryParams` method in the Form object allows us to convert a form into query parameters: ```scala val queryParams: QueryParams = form.toQueryParams // queryParams: QueryParams = JavaLinkedHashMapQueryParams( // underlying = {username=[johndoe], password=[secretpassword], age=[42]} // ) queryParams.encode // res12: String = "?username=johndoe&password=secretpassword&age=42" ``` --- ## Template The package `zio.http.template._` contains lightweight helpers for generating statically typed, safe html similar in spirit to `scalatags`. ## Html and DOM ### Html from string One possible way is to create an instance of `Html` directly from a `String` value, with the obvious drawback of not having checks from the compiler: ```scala val divHtml1: Html = Html.fromString("""ZIO Homepage""") ``` ### Html from constructors In order to improve on type safety one could use `Html` with `Dom` constructor functions, with the drawback that the resulting code is much more verbose: ```scala val divHtml2: Html = Html.fromDomElement( Dom.element( "div", Dom.attr("class", "container1 container2"), Dom.element( "a", Dom.attr("href", "http://zio.dev"), Dom.text("ZIO Homepage") ) ) ) ``` Please note that both values `divHtml1` and `divHtml2` produce identical html output. ### Html from Tag API Practically one would very likely not use one of the above mentioned versions but instead use the `Tag API`. That API lets one use not only html elements like `div` or `a` but also html attributes like `hrefAttr` or `styleAttr` as scala functions. By convention values of html attributes are suffixed `attr` to easily distinguish those from html elements: ```scala val divHtml3: Html = div( classAttr := "container1 container2", a(hrefAttr := "http://zio.dev", "ZIO Homepage") ) ``` Also `divHtml3` produces identical html output as `divHtml1` and `divHtml2`. Html elements like `div` or `a` are represented as values of `PartialElement` which have an `apply` method for nesting html elements, html attributes and text values. Html attributes are represented as values of `PartialAttribute` which provides an operator `:=` for "assigning" attribute values. Besides `:=` attributes also have an `apply` method that provides an alternative syntax e.g. instead of `a(hrefAttr := "http://zio.dev", "ZIO Homepage")` one can use `a(hrefAttr("http://zio.dev"), "ZIO Homepage")`. ### Html composition One can compose values of `Html` sequentially using the operator `++` to produce a larger `Html`: ```scala val fullHtml: Html = divHtml1 ++ divHtml2 ++ divHtml3 ``` ## Html response One can create a successful http response in routing code from a given value of `Html` with `Response.html`. --- ## Client `ZClient` is an HTTP client that enables us to make HTTP requests and handle responses in a purely functional manner. ZClient leverages the ZIO library's capabilities to provide a high-performance, asynchronous, and type-safe HTTP client solution. ## Key Features **Purely Functional**: ZClient is built on top of the ZIO library, enabling a purely functional approach to handling HTTP requests and responses. This ensures referential transparency and composability, making it easy to build and reason about complex HTTP interactions. **Type-Safe**: ZClient's API is designed to be type-safe, leveraging Scala's type system to catch errors at compile time and provide a seamless development experience. This helps prevent common runtime errors and enables developers to write robust and reliable HTTP client code. **Asynchronous & Non-blocking**: ZClient is fully asynchronous and non-blocking, allowing us to perform multiple HTTP requests concurrently without blocking threads. This ensures optimal resource utilization and scalability, making it suitable for high-performance applications. **Middleware Support**: ZClient provides support for middleware, allowing us to customize and extend its behavior to suit our specific requirements. We can easily plug in middleware to add functionalities such as logging, debugging, caching, and more. **Flexible Configuration**: ZClient offers flexible configuration options, allowing us to fine-tune its behavior according to our needs. We can configure settings such as SSL, proxy, connection pooling, timeouts, and more to optimize the client's performance and behavior. **WebSocket Support**: In addition to traditional HTTP requests, ZClient also supports WebSocket communication, enabling bidirectional, full-duplex communication between client and server over a single, long-lived connection. **SSL Support**: ZClient provides built-in support for SSL (Secure Sockets Layer) connections, allowing secure communication over the network. Users can configure SSL settings such as certificates, trust stores, and encryption protocols to ensure data confidentiality and integrity. ## Making HTTP Requests We can think of a `ZClient` as a function that takes a `Request` and returns a `ZIO` effect that calls the server with the given request and returns the response that the server sends back. Requests can be executed in 2 modes: - `batched`: The entire body of the request is materialized in memory, and the connection lifecycle is managed automatically by the client. - `streaming`: The body of the request _might be_ streaming, and the connection lifecycle is managed through the `Scope` in the effect's environment. The `Client`'s companion object contains methods that reflect the 2 modes of request execution: ```scala object Client { def batched(request: Request): ZIO[Client, Throwable, Response] = ??? def streaming(request: Request): ZIO[Client & Scope, Throwable, Response] = ??? } ``` ### "Streaming" Client The `streaming` mode is the default mode for executing HTTP requests. It requires the `Client` and `Scope` environments to perform the request and handle the response. The `Client` environment is used to make the request, while the `Scope` environment is used to manage the lifecycle of resources such as connections, sockets, and other I/O-related resources that are acquired and released during the request-response operation. When making a request in the `streaming` mode, we need to explicitly close the `Scope` once we've collected the response body: ```scala // OK val good = ZIO.scoped { Client .streaming(Request.get("http://jsonplaceholder.typicode.com/todos")) .flatMap(_.body.asString) }.flatMap(???) // BAD: The server might be streaming the response body, and we've forcefully closed the connection before it finishes val bad1 = ZIO.scoped { Client .streaming(Request.get("http://jsonplaceholder.typicode.com/todos")) .map(_.headers) } .flatMap(???) // BAD: We're closing the scope before collecting the response body val bad2 = ZIO.scoped { Client .streaming(Request.get("http://jsonplaceholder.typicode.com/todos")) } .flatMap(_.body.asString) .flatMap(???) // VERY BAD: The connection will not be closed until the application exits, which will lead to resource leaks! val bad3 = Client .streaming(Request.get("http://jsonplaceholder.typicode.com/todos")) .flatMap(_.body.asString) .flatMap(???) .provideSomeLayer[Client](Scope.default) ``` :::note As a rule of thumb, you should **never** use `Scope.default` with Client! To learn more about resource management and `Scope` in ZIO, refer to the [dedicated guide on this topic](https://zio.dev/reference/resource/scope) in the ZIO Core documentation. ::: ### "Batched" Client Handling of `Scope` can quickly become cumbersome in cases where we simply want to execute an HTTP request and not handle the lifetime of the HTTP request. The `batched` mode is simply a sub-implementation of the `streaming` mode where the `Scope` (i.e., connection lifecycle) is managed automatically. Executing a request via the `batched` method can be done as simply as: ```scala val good = Client .batched(Request.get("http://jsonplaceholder.typicode.com/todos")) .flatMap(_.body.asString) .flatMap(???) ``` ::: warning The `batched` methods will materialize the entire body of the request to memory. Use this only when you don't need to stream the request body! ::: We can similarly use the `batched` method on an instance of `Client` to return a new instance where all the methods will be executed in the `batched` mode. Below is a realistic example showcasing the usage of the `batched` client: ```scala case class Todo( userId: Int, id: Int, title: String, completed: Boolean, ) object Todo { implicit val todoSchema = DeriveSchema.gen[Todo] } final class JsonPlaceHolderService(baseClient: Client) { private val client = baseClient.batched def todos(): ZIO[Any, Throwable, List[Todo]] = client .request(Request.get("http://jsonplaceholder.typicode.com/todos")) .flatMap(_.body.to[List[Todo]]) } ``` ZIO HTTP has several utility methods to create different types of requests, such as `Client#get`, `Client#post`, `Client#put`, `Client#delete`, etc: | Method | Description | |--------------------------------------|-----------------------------------------------------------------------| | `def get(suffix: String)` | Performs a GET request with the given path suffix. | | `def head(suffix: String)` | Performs a HEAD request with the given path suffix. | | `def patch(suffix: String)` | Performs a PATCH request with the given path suffix. | | `def post(suffix: String)(body: In)` | Performs a POST request with the given path suffix and provided body. | | `def put(suffix: String)(body: In)` | Performs a PUT request with the given path suffix and provided body. | | `def delete(suffix: String)` | Performs a DELETE request with the given path suffix. | ## Performing WebSocket Connections We can also think of a client as a function that takes a `WebSocketApp` and returns a `ZIO` effect that performs the WebSocket operations and returns a response: ```scala object ZClient { def socket[R](socketApp: WebSocketApp[R]): ZIO[R with Client & Scope, Throwable, Response] = ??? } ``` :::note The `socket` method is not available on the "Batched" client! ::: Here is a simple example of how to use the `ZClient#socket` method to perform a WebSocket connection: ```scala object WebSocketSimpleClient extends ZIOAppDefault { val url = "ws://ws.vi-server.org/mirror" val socketApp: WebSocketApp[Any] = Handler // Listen for all websocket channel events .webSocket { channel => channel.receiveAll { // Send a "foo" message to the server once the connection is established case UserEventTriggered(UserEvent.HandshakeComplete) => channel.send(Read(WebSocketFrame.text("foo"))) *> ZIO.debug("Connection established and the foo message sent to the server") // Send a "bar" if the server sends a "foo" case Read(WebSocketFrame.Text("foo")) => channel.send(Read(WebSocketFrame.text("bar"))) *> ZIO.debug("Received the foo message from the server and the bar message sent to the server") // Close the connection if the server sends a "bar" case Read(WebSocketFrame.Text("bar")) => ZIO.debug("Received the bar message from the server and Goodbye!") *> channel.send(Read(WebSocketFrame.close(1000))) case _ => ZIO.unit } } val app: ZIO[Client, Throwable, Unit] = for { url <- ZIO.fromEither(URL.decode("ws://ws.vi-server.org/mirror")) client <- ZIO.serviceWith[Client](_.url(url)) _ <- ZIO.scoped(client.socket(socketApp) *> ZIO.never) } yield () val run: ZIO[Any, Throwable, Any] = app.provide(Client.default) } ``` In the above example, we defined a WebSocket client that connects to a mirror server and sends and receives messages. When the connection is established, it receives the `UserEvent.HandshakeComplete` event and then it sends a "foo" message to the server. Consequently, the server sends a "foo" message, and the client responds with a "bar" message. Finally, the server sends a "bar" message, and the client closes the connection. ## Configuring Headers As the `ZClient` extends the `HeaderOps` trait, we have access to all operations that can be performed on headers inside the client. For example, to add a custom header, we can use the `Client#addHeader` method: ```scala val program = for { client <- ZIO.serviceWith[Client](_.addHeader(Authorization.Bearer(token = "dummyBearerToken"))) res <- client.request(Request.get("http://localhost:8080/users")) } yield () ``` By default, the client adds the `User-Agent` header to all requests. Please note that it also automatically calculates and sets the `Content-Length` header, disregarding any `Content-Length` value manually configured by the user. :::note To learn more about headers and how they work, check out our dedicated section called [Header Operations](headers/headers.md#headers-operations) on the headers page. ::: ## Composable URLs In ZIO HTTP, URLs are composable. This means that if we have two URLs, we can combine them to create a new URL. This is useful when we want to prevent duplication of the base URL in our code. For example, assume we have a base URL `http://localhost:8080` and we want to make several requests to different endpoints and query parameters under this base URL. We can configure the client with this URL using the `Client#url` and then every request will be made can be relative to this base URL: ```scala case class User(name: String, age: Int) object User { implicit val schema = DeriveSchema.gen[User] } val program: ZIO[Client, Throwable, Unit] = for { client <- ZIO.serviceWith[Client](_.url(url"http://localhost:8080").batched) _ <- client.post("/users")(Body.from(User("John", 42))) res <- client.get("/users") _ <- client.delete("/users/1") _ <- res.body.asString.debug } yield () ``` The following methods are available for setting the base URL: | Method Signature | Description | |---------------------------------|------------------------------------------------| | `Client#url(url: URL)` | Sets the URL directly. | | `Client#uri(uri: URI)` | Sets the URL from the provided URI. | | `Client#path(path: String)` | Sets the path of the URL from a string. | | `Client#path(path: Path)` | Sets the path of the URL from a `Path` object. | | `Client#port(port: Int)` | Sets the port of the URL. | | `Client#scheme(scheme: Scheme)` | Sets the scheme (protocol) for the URL. | The `Scheme` is a sealed trait that represents the different schemes (protocols) that can be used in a request. The available schemes are `HTTP` and `HTTPS` for HTTP requests, and `WS` and `WSS` for WebSockets. Here is the list of methods that are available for adding URL, Path, and QueryParams to the client's configuration: | Methods | Description | |----------------------------------------------------|------------------------------------------------------------------------| | `Client#addUrl(url: URL)` | Adds another URL to the existing one. | | `Client#addPath(path: String)` | Adds a path segment to the URL. | | `Client#addPath(path: Path)` | Adds a path segment from a `Path` object to the URL. | | `Client#addLeadingSlash` | Adds a leading slash to the URL path. | | `Client#addTrailingSlash` | Adds a trailing slash to the URL path. | | `Client#addQueryParam(key: String, value: String)` | Adds a query parameter with the specified key-value pair to the URL. | | `Client#addQueryParams(params: QueryParams)` | Adds multiple query parameters to the URL from a `QueryParams` object. | ## Client Aspects/Middlewares Client aspects are a powerful feature of ZIO HTTP, enabling us to intercept, modify, and extend client behavior. The `ZClientAspect` is represented as a function that takes a `ZClient` and returns a new `ZClient` with customized behavior. We apply aspects to a client using the `ZClient#@@` method, allowing modification of various execution aspects such as metrics, tracing, encoding, decoding, and debugging. ### Debugging Aspects To debug the client, we can use the `ZClientAspect.debug` aspect, which logs the request details to the console. This is useful for debugging and troubleshooting client interactions, as it provides visibility into the low-level details of the HTTP requests and responses: ```scala object ClientWithDebugAspect extends ZIOAppDefault { val program = for { client <- ZIO.service[Client].map(_ @@ ZClientAspect.debug) _ <- client.batched(Request.get("http://jsonplaceholder.typicode.com/todos")) } yield () override val run = program.provide(Client.default) } ``` The `ZClientAspect.debug` also takes a partial function from `Response` to `String`, which enables us to customize the logging output based on the response. This is useful for logging specific details from the response, such as status code, headers, and body: ```scala val debugResponse = ZClientAspect.debug { case res: Response => res.headers.mkString("\n") } val program = for { client <- ZIO.service[Client].map(_ @@ debugResponse) _ <- client.request(Request.get("http://jsonplaceholder.typicode.com/todos")) } yield () ``` ### Logging Aspects To log the client interactions, we can use the `ZClientAspect.requestLogging` which logs the request details such as method, duration, url, user-agent, status code and request size. Let's try an example: ```scala val loggingAspect = ZClientAspect.requestLogging( loggedRequestHeaders = Set(Header.UserAgent), logResponseBody = true, ) val program = for { client <- ZIO.service[Client].map(_ @@ loggingAspect) _ <- client.request(Request.get("http://jsonplaceholder.typicode.com/todos")) } yield () ``` ### Follow Redirects To follow redirects, we can apply the `ZClientAspect.followRedirects` aspect, which takes the maximum number of redirects to follow and a callback function that allows us to customize the behavior when a redirect is encountered: ```scala val followRedirects = ZClientAspect.followRedirects(3)((resp, message) => ZIO.logInfo(message).as(resp)) for { client <- ZIO.service[Client].map(_ @@ followRedirects) response <- client.request(Request.get("http://google.com")) _ <- response.body.asString.debug } yield () ``` ### Curl Logger The `ZClientAspect.curlLogger` aspect converts HTTP requests into equivalent curl commands and logs them. This is particularly useful for debugging, documentation, and testing, as it allows you to see the exact curl command that corresponds to each HTTP request made by your client. The aspect takes two optional parameters: - `verbose: Boolean` - Whether to include the `--verbose` flag in the curl command (default: `true`) - `logEffect: String => UIO[Unit]` - A custom logging function (default: `ZIO.log`) Here's a basic example: ```scala title="zio-http-example/src/main/scala/example/CurlLoggerExample.scala" package example @nowarn("msg=deprecated") object CurlLoggerExample extends ZIOAppDefault { val program = for { client <- ZIO.service[Client].map(_ @@ ZClientAspect.curlLogger(logEffect = s => ZIO.debug("CURL: " + s))) response <- client.request( Request .post( "https://gorest.co.in/public/v2/users", Body.fromString( """{ | "name": "John Doe", | "gender": "male", | "email": "john.doe.unique123@example.com", | "status": "active" |}""".stripMargin, ), ) .addHeader(Header.ContentType(MediaType.application.json)) .addHeader(Header.Authorization.Bearer("")), ) _ <- response.body.asString.debug } yield () override val run = program.provide(Client.default, Scope.default) } ``` This will log a curl command similar to: ```bash curl \ --verbose \ --request POST \ --header 'user-agent:Zio-Http-Client/2.1.21 (Scala 2.13.16)' \ --header 'content-type:application/json' \ --header 'authorization:Bearer ' \ --data '{ "name": "John Doe", "gender": "male", "email": "john.doe.unique123@example.com", "status": "active" }' \ 'https://gorest.co.in/public/v2/users' ``` You can customize the logging behavior by providing your own logging function. For example, to write curl commands to a file. You can also use the `CurlLogger.formatCurlCommand` helper directly without applying it as an aspect to the client: ```scala val program = for { request <- ZIO.succeed(Request.get("https://example.com/")) curlCommand <- ZClientAspect.CurlLogger.formatCurlCommand(request, verbose = true) _ <- Console.printLine(curlCommand) } yield () ``` The curl logger is useful when you want to generate curl commands for documentation or testing purposes without actually executing the requests. ### Forwarding Headers This is essential when your application acts as an intermediary (proxy, API gateway, or microservice) and needs to forward specific headers from the original request to downstream services. Assume you have an API gateway that handles incoming requests and forwards them to various backend services. You want to ensure that certain headers, such as authentication tokens or tracing information, are preserved during this forwarding process. To achieve this, you can use the `ZClientAspect.forwardHeaders` aspect. This aspect works in conjunction with the `Middleware.forwardHeaders` middleware. The Two-Part System: - **Server Middleware** (`Middleware.forwardHeaders`) - Extracts specified headers from incoming requests and stores them in request-scoped storage (`RequestStore`) - **Client Aspect** (`ZClientAspect.forwardHeaders`) - Retrieves headers from the request-scoped storage and automatically adds them to outgoing requests The process begins when an external client makes a request to your API gateway. For example, a client might send a GET request to retrieve order information, including an authorization bearer token and a request ID for distributed tracing. When this request arrives at your server, the `Middleware.forwardHeaders` middleware immediately goes to work. You configure this middleware by specifying which headers you want to forward—for instance, the `Authorization` header and a custom `X-Request-ID` header. The middleware extracts these headers from the incoming request and creates a request-scoped storage container. This storage uses ZIO's `FiberRef` mechanism under the hood, ensuring that the headers are available throughout the entire request handling process but are completely isolated from other concurrent requests. The beauty of this approach is that the storage is request-scoped, meaning it lives only for the duration of that specific request and is automatically cleaned up when the request completes. You don't need to worry about memory leaks or manual cleanup. More about `RequestStore` can be found in the [dedicated section](contextual/request-store.md). Once the middleware has stored the headers, your request handler executes normally. In your handler, you make HTTP requests to various backend services using a ZIO HTTP Client that has been configured with the `ZClientAspect.forwardHeaders` aspect. This is where the magic happens—you don't need to manually add the forwarded headers to each outgoing request. Instead, you simply make your HTTP calls as you normally would. Behind the scenes, the client aspect intercepts each outgoing request before it's sent. It reads the forwarded headers from the request-scoped storage and automatically merges them with the outgoing request. So when you make a request to your order service or user service, the aspect ensures that the `Authorization` token and `X-Request-ID` are automatically included in those requests. Here is an example demonstrating this flow: ```scala object ApiGateway extends ZIOAppDefault { val userServiceUrl = "http://user-service:8081" val orderServiceUrl = "http://order-service:8082" val inventoryServiceUrl = "http://inventory-service:8083" val routes = Routes( Method.GET / "api" / "orders" / string("orderId") -> Handler.scoped { handler { (orderId: String, req: Request) => for { client <- ZIO.serviceWith[Client](_ @@ ZClientAspect.forwardHeaders) order <- client .request(Request.get(s"$orderServiceUrl/orders/$orderId")) .flatMap(_.body.to[Order]) user <- client .request(Request.get(s"$userServiceUrl/users/me")) .flatMap(_.body.to[User]) inventory <- client .request(Request.get(s"$inventoryServiceUrl/stock/$orderId")) .flatMap(_.body.to[Inventory]) } yield Response( headers = Headers(Header.ContentType(MediaType.application.json)), body = Body.from[OrderDetails](OrderDetails(order, user, inventory)), ) } }, ).sandbox @@ Middleware.forwardHeaders(Header.Authorization) def run = Server.serve(routes).provide(Server.default, Client.default) } ``` The result is that all your downstream services receive the necessary headers without any manual header manipulation in your code. Here is the visual flow: ```text ┌─────────────────────────────────────────────────────────────────────────────┐ │ 1. INCOMING CLIENT REQUEST │ │ │ │ curl -H "Authorization: Bearer token123" \ │ │ -H "X-Request-ID: req-456" \ │ │ http://api-gateway/api/orders/123 │ │ │ │ ┌──────────────────────────────────────────────┐ │ │ │ GET /api/orders/123 │ │ │ │ Authorization: Bearer token123 │ │ │ │ X-Request-ID: req-456 │ │ │ └──────────────────────────────────────────────┘ │ └─────────────────────────────────┬───────────────────────────────────────────┘ │ ▼ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ 2. SERVER MIDDLEWARE: Middleware.forwardHeaders(...) ┃ ┃ ───────────────────────────────────────────────────────────────── ┃ ┃ ┃ ┃ YOU WRITE: ┃ ┃ ═════════ ┃ ┃ routes @@ Middleware.forwardHeaders( ┃ ┃ Header.Authorization, ┃ ┃ Header.Custom("X-Request-ID") ┃ ┃ ) ┃ ┃ ┃ ┃ AUTOMATICALLY HAPPENS: ┃ ┃ ════════════════════ ┃ ┃ ┌────────────────────────────────────────────────────────┐ ┃ ┃ │ a) Extract specified headers from incoming request: │ ┃ ┃ │ • Authorization: Bearer token123 │ ┃ ┃ │ • X-Request-ID: req-456 │ ┃ ┃ │ │ ┃ ┃ │ b) Create request-scoped storage (FiberRef-based): │ ┃ ┃ │ RequestStore[ForwardedHeaders] = { │ ┃ ┃ │ headers: Headers( │ ┃ ┃ │ Authorization: Bearer token123, │ ┃ ┃ │ X-Request-ID: req-456 │ ┃ ┃ │ ) │ ┃ ┃ │ } │ ┃ ┃ │ │ ┃ ┃ │ c) Storage scope: Lives only for this request │ ┃ ┃ │ Automatically cleaned up when request completes │ ┃ ┃ └────────────────────────────────────────────────────────┘ ┃ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┯━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ │ ╔═════════╧════════════════════════════════╗ ║ REQUEST-SCOPED CONTEXT CREATED ║ ║ ┌────────────────────────────────────┐ ║ ║ │ RequestStore[ForwardedHeaders] │ ║ ║ │ • Authorization: Bearer token123 │ ║ ║ │ • X-Request-ID: req-456 │ ║ ║ └────────────────────────────────────┘ ║ ║ (Available to entire request lifecycle) ║ ╚═══════════════════╤══════════════════════╝ │ ▼ ┌─────────────────────────────────────────────────────────────────────────────┐ │ 3. YOUR HANDLER EXECUTES │ │ ───────────────────── │ │ │ │ YOU WRITE: │ │ ═════════ │ │ for { │ │ client <- ZIO.serviceWith[Client](_ @@ ZClientAspect.forwardHeaders) │ │ │ │ order <- client.request( │ │ Request.get("http://order-service:8082/orders/123") │ │ ).flatMap(_.body.to[Order]) │ │ │ │ user <- client.request( │ │ Request.get("http://user-service:8081/users/me") │ │ ).flatMap(_.body.to[User]) │ │ } yield Response(...) │ │ │ │ NOTE: You don't manually add headers - that's the whole point! │ └─────────────────────────────────┬───────────────────────────────────────────┘ │ ▼ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ 4. CLIENT ASPECT: ZClientAspect.forwardHeaders ┃ ┃ ─────────────────────────────────────────────────────────── ┃ ┃ ┃ ┃ YOU WRITE: ┃ ┃ ═════════ ┃ ┃ @@ ZClientAspect.forwardHeaders ┃ ┃ ┃ ┃ AUTOMATICALLY HAPPENS (for EACH outgoing request): ┃ ┃ ═════════════════════════════════════════════════ ┃ ┃ ┌────────────────────────────────────────────────────────┐ ┃ ┃ │ a) Read from RequestStore[ForwardedHeaders]: │ ┃ ┃ │ headers = { │ ┃ ┃ │ Authorization: Bearer token123, │ ┃ ┃ │ X-Request-ID: req-456 │ ┃ ┃ │ } │ ┃ ┃ │ │ ┃ ┃ │ b) Merge with outgoing request: │ ┃ ┃ │ Original: Request.get("http://order-service/...") │ ┃ ┃ │ Enhanced: Request.get("http://order-service/...") │ ┃ ┃ │ + Authorization: Bearer token123 │ ┃ ┃ │ + X-Request-ID: req-456 │ ┃ ┃ │ │ ┃ ┃ │ c) Send enhanced request to backend service │ ┃ ┃ └────────────────────────────────────────────────────────┘ ┃ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┯━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ │ ┌─────────────────┴─────────────────┐ │ │ ▼ ▼ ┌────────────────────────────────┐ ┌────────────────────────────────┐ │ 5a. ORDER SERVICE RECEIVES │ │ 5b. USER SERVICE RECEIVES │ │ │ │ │ │ GET /orders/123 │ │ GET /users/me │ │ Authorization: Bearer token123│ │ Authorization: Bearer token123│ │ X-Request-ID: req-456 │ │ X-Request-ID: req-456 │ │ ✓ Forwarded automatically! │ │ ✓ Forwarded automatically! │ └────────────────────────────────┘ └────────────────────────────────┘ ``` ## Configuring ZIO HTTP Client The ZIO HTTP Client provides a flexible configuration mechanism through the `ZClient.Config` class. This class allows us to customize various aspects of the HTTP client, including SSL settings, proxy configuration, connection pool size, timeouts, and more. The `ZClient.Config.default` provides a default configuration that can be customized using `copy` method or by using the utility methods provided by the `ZClient.Config` class. Let's take a look at the available configuration options: - **SSL Configuration**: Allows us to specify SSL settings for secure connections. - **Proxy Configuration**: Enables us to configure a proxy server for outgoing HTTP requests. - **Connection Pool Configuration**: Defines the size of the connection pool. - **Max Initial Line Length**: Sets the maximum length of the initial line in an HTTP request or response. The default is set to 4096 characters. - **Max Header Size**: Specifies the maximum size of HTTP headers in bytes. The default is set to 8192 bytes. - **Request Decompression**: Specifies whether the client should decompress the response body if it's compressed. - **Local Address**: Specifies the local network interface or address to use for outgoing connections. It's set to None, indicating that the client will use the default local address. - **Add User-Agent Header**: Indicates whether the client should automatically add a User-Agent header to outgoing requests. It's set to true in the default configuration. - **WebSocket Configuration**: Configures settings specific to WebSocket connections. In this example, the default WebSocket configuration is used. - **Idle Timeout**: Specifies the maximum idle time for persistent connections in seconds. The default is set to 50 seconds. - **Connection Timeout**: Specifies the maximum time to wait for establishing a connection in seconds. By default, the client has no connection timeout. Here are some of the above configuration options in more detail: ### Configuring SSL The default SSL configuration of `ZClient.Config.default` is `None`. To enable and configure SSL for the client, we can use the `ZClient.Config#ssl` method. This method takes a config of type `ClientSSLConfig` which supports different SSL configurations such as `Default`, `FromCertFile`, `FromCertResource`, `FromTrustStoreFile`, and `FromTrustStoreResource. Let's see an example of how to configure SSL for the client: ```scala title="zio-http-example/src/main/scala/example/HttpsClient.scala" //> using dep "dev.zio::zio-http:3.4.0" package example object HttpsClient extends ZIOAppDefault { val url = URL.decode("https://jsonplaceholder.typicode.com/todos/1").toOption.get val headers = Headers(Header.Host("jsonplaceholder.typicode.com")) val sslConfig = ClientSSLConfig.FromTrustStoreResource( trustStorePath = "truststore.jks", trustStorePassword = "changeit", ) val clientConfig = ZClient.Config.default.ssl(sslConfig) val program = for { data <- ZClient.batched(Request.get(url).addHeaders(headers)) _ <- Console.printLine(data) } yield () val run = program.provide( ZLayer.succeed(clientConfig), Client.customized, NettyClientDriver.live, DnsResolver.default, ZLayer.succeed(NettyConfig.default), ) } ``` ### Configuring Proxy To configure a proxy for the client, we can use the `Client#proxy` method. This method takes a `Proxy` and updates the client's configuration to use the specified proxy for all requests: ```scala val program = for { proxyUrl <- ZIO.fromEither(URL.decode("http://localhost:8123")) client <- ZIO.serviceWith[Client](_.proxy(Proxy(url = proxyUrl))) res <- client.request(Request.get("https://jsonplaceholder.typicode.com/todos")) } yield () ``` ### Connection Pooling Connection pooling is a crucial mechanism in ZIO HTTP for optimizing the management of HTTP connections. By default, ZIO HTTP uses a fixed-size connection pool with a capacity of 10 connections. This means that the client can maintain up to 10 idle connections to the server for reuse. When the client makes a request, it checks the connection pool for an available connection to the server. If a connection is available, it reuses it for the request. If no connection is available, it creates a new connection and adds it to the pool. To configure the connection pool, we have to update the `ZClient.Config#connectionPool` field with the preferred configuration. The `ConnectionPoolConfig` trait serves as a base trait for different connection pool configurations. It is a sealed trait with five different implementations: - `Disabled`: Indicates that connection pooling is disabled. - `Fixed`: Takes a single parameter, `size`, which specifies a fixed size connection pool. - `FixedPerHost`: Takes a map of `URL.Location.Absolute` to `Fixed` to specify a fixed size connection pool per host. - `Dynamic`: Takes three parameters, `minimum`, `maximum`, and `ttl`, to configure a dynamic connection pool with minimum and maximum sizes and a time-to-live (TTL) duration. - `DynamicPerHost`: Similar to Dynamic, but with configurations per host. Also the `ZClient.Config` has some utility methods to update the connection pool configuration, e.g. `ZClient.Config#fixedConnectionPool` and `ZClient.Config#dynamicConnectionPool`. Let's see an example of how to configure the connection pool: ```scala title="zio-http-example/src/main/scala/example/ClientWithConnectionPooling.scala" //> using dep "dev.zio::zio-http:3.4.0" package example object ClientWithConnectionPooling extends ZIOAppDefault { val program = for { url <- ZIO.fromEither(URL.decode("http://jsonplaceholder.typicode.com/posts")) client <- ZIO.serviceWith[Client](_.addUrl(url)) _ <- ZIO.foreachParDiscard(Chunk.fromIterable(1 to 100)) { i => client.batched(Request.get(i.toString)).flatMap(_.body.asString).debug } } yield () val config = ZClient.Config.default.dynamicConnectionPool(10, 20, 5.second) override val run = program.provide( ZLayer.succeed(config), Client.live, ZLayer.succeed(NettyConfig.default), DnsResolver.default, ) } ``` ### Enabling Response Decompression When making HTTP requests using a client, such as a web browser or a custom HTTP client, it's essential to optimize data transfer for efficiency and performance. By default, most HTTP clients do not advertise compression support when making requests to web servers. However, servers often compress response bodies when they detect that the client supports compression. To enable response compression, we need to add the `Accept-Encoding` header to our HTTP requests. The `Accept-Encoding` header specifies the compression algorithms supported by the client. Common values include `gzip` and `deflate`. When a server receives a request with the `Accept-Encoding` header, it may compress the response body using one of the specified algorithms. Here's an example of an HTTP request with the Accept-Encoding header: ```http GET https://example.com/ Accept-Encoding: gzip, deflate ``` When a server responds with a compressed body, it includes the Content-Encoding header to specify the compression algorithm used. The client then needs to decompress the body before processing its contents. For example, a compressed response might look like this: ```http 200 OK content-encoding: gzip content-type: application/json; charset=utf-8 ``` To decompress the response body with `ZClient`, we need to enable response decompression by using the `ZClient.Config#requestDecompression` method: ```scala title="zio-http-example/src/main/scala/example/ClientWithDecompression.scala" //> using dep "dev.zio::zio-http:3.4.0" package example object ClientWithDecompression extends ZIOAppDefault { val program = for { url <- ZIO.fromEither(URL.decode("https://jsonplaceholder.typicode.com")) client <- ZIO.serviceWith[Client](_.addUrl(url)) res <- client .addHeader(AcceptEncoding(AcceptEncoding.GZip(), AcceptEncoding.Deflate())) .batched(Request.get("/todos")) data <- res.body.asString _ <- Console.printLine(data) } yield () val config = ZClient.Config.default.requestDecompression(true) override val run = program.provide( ZLayer.succeed(config), Client.live, ZLayer.succeed(NettyConfig.default), DnsResolver.default, ) } ``` ## Customizing `ClientDriver` and `DnsResolver` Rather than utilizing the default layer, `Client.default`, we have the option to employ the `Client.customized` layer. This layer requires `ClientDriver`, `DnsResolver`, and the `Client.Config` layers: ```scala object Client { val customized: ZLayer[Config with ClientDriver with DnsResolver, Throwable, Client] = ??? } ``` This empowers us to interchange the client driver with alternatives beyond the default Netty driver or to customize it to our specific requirements. Also, we can customize the DNS resolver to use a different DNS resolution mechanism. ## Examples ### Simple Client Example ```scala title="zio-http-example/src/main/scala/example/SimpleClient.scala" //> using dep "dev.zio::zio-http:3.4.0" package example object SimpleClient extends ZIOAppDefault { val url = URL.decode("https://jsonplaceholder.typicode.com/todos").toOption.get val program = for { client <- ZIO.service[Client] res <- client.url(url).batched(Request.get("/")) data <- res.body.asString _ <- Console.printLine(data) } yield () override val run = program.provide(Client.default) } ``` ### ClientServer Example ```scala title="zio-http-example/src/main/scala/example/ClientServer.scala" //> using dep "dev.zio::zio-http:3.4.0" package example object ClientServer extends ZIOAppDefault { private val url = URL.decode("http://localhost:8080/hello").toOption.get private val app = Routes( Method.GET / "hello" -> handler(Response.text("hello")), Method.GET / "" -> handler(ZClient.batched(Request.get(url))), ).sandbox override val run: ZIO[Environment with ZIOAppArgs with Scope, Any, Any] = Server.serve(app).provide(Server.default, Client.default) } ``` ### Authentication Client Example This example code demonstrates accessing a protected route in an [authentication server](https://github.com/zio/zio-http/blob/main/zio-http-example/src/main/scala/example/AuthenticationClient.scala) by first obtaining a JWT token through a login request and then using that token to access the protected route: ```scala title="zio-http-example/src/main/scala/example/AuthenticationClient.scala" //> using dep "dev.zio::zio-http:3.4.0" package example object AuthenticationClient extends ZIOAppDefault { /** * This example is trying to access a protected route in AuthenticationServer * by first making a login request to obtain a jwt token and use it to access * a protected route. Run AuthenticationServer before running this example. */ val url = "http://localhost:8080" val loginUrl = URL.decode(s"${url}/login").toOption.get val greetUrl = URL.decode(s"${url}/profile/me").toOption.get val program = for { client <- ZIO.service[Client] // Making a login request to obtain the jwt token. In this example the password should be the reverse string of username. token <- client .batched( Request .get(loginUrl) .withBody( Body.fromMultipartForm( Form( FormField.simpleField("username", "John"), FormField.simpleField("password", "nhoJ"), ), Boundary("boundary123"), ), ), ) .flatMap(_.body.asString) // Once the jwt token is procured, adding it as a Bearer token in Authorization header while accessing a protected route. response <- client.batched(Request.get(greetUrl).addHeader(Header.Authorization.Bearer(token))) body <- response.body.asString _ <- Console.printLine(body) } yield () override val run = program.provide(Client.default) } ``` ### Reconnecting WebSocket Client Example This example represents a WebSocket client application that automatically attempts to reconnect upon encountering errors or disconnections. It uses the `Promise` to notify about WebSocket errors: ```scala title="zio-http-example/src/main/scala/example/websocket/WebSocketReconnectingClient.scala" package example.websocket object WebSocketReconnectingClient extends ZIOAppDefault { val url = "ws://ws.vi-server.org/mirror" // A promise is used to be able to notify application about websocket errors def makeSocketApp(p: Promise[Nothing, Throwable]): WebSocketApp[Any] = Handler // Listen for all websocket channel events .webSocket { channel => channel.receiveAll { // On connect send a "foo" message to the server to start the echo loop case UserEventTriggered(UserEvent.HandshakeComplete) => channel.send(ChannelEvent.Read(WebSocketFrame.text("foo"))) // On receiving "foo", we'll reply with another "foo" to keep echo loop going case Read(WebSocketFrame.Text("foo")) => ZIO.logInfo("Received foo message.") *> ZIO.sleep(1.second) *> channel.send(ChannelEvent.Read(WebSocketFrame.text("foo"))) // Handle exception and convert it to failure to signal the shutdown of the socket connection via the promise case ExceptionCaught(t) => ZIO.fail(t) case _ => ZIO.unit } }.tapErrorZIO { f => // signal failure to application p.succeed(f) } val app: ZIO[Client & Scope, Throwable, Unit] = { (for { p <- zio.Promise.make[Nothing, Throwable] _ <- makeSocketApp(p).connect(url).catchAll { t => // convert a failed connection attempt to an error to trigger a reconnect p.succeed(t) } f <- p.await _ <- ZIO.logError(s"App failed: $f") _ <- ZIO.logError(s"Trying to reconnect...") _ <- ZIO.sleep(1.seconds) } yield { () }) *> app } val run = ZIO.scoped(app).provide(Client.default) } ``` --- ## ConnectionPoolConfig --- id: connectionpoolconfig title: zio.http.ConnectionPoolConfig sidebar_label: ConnectionPoolConfig --- ## Configuration Details |FieldName|Format |Description|Sources| |--- |--- |--- |--- | | |[any-one-of](#field-descriptions)| | | ### Field Descriptions |FieldName |Format |Description |Sources| |--- |--- |--- |--- | | |primitive |a text property | | |fixed |primitive |an integer property| | |[dynamic](#dynamic-1)|[all-of](#dynamic-1)| | | |[fixed](#fixed) |[all-of](#fixed) | | | |[dynamic](#dynamic) |[all-of](#dynamic) | | | ### dynamic |FieldName|Format |Description |Sources| |--- |--- |--- |--- | |minimum |primitive|an integer property| | |maximum |primitive|an integer property| | |ttl |primitive|a duration property| | ### fixed |FieldName |Format |Description|Sources| |--- |--- |--- |--- | |[per-host](#per-host)|[list](#per-host) | | | |[default](#default) |[nested](#default)| | | ### per-host |FieldName|Format |Description |Sources| |--- |--- |--- |--- | |url |primitive|a text property | | |fixed |primitive|an integer property| | ### default |FieldName|Format |Description |Sources| |--- |--- |--- |--- | |fixed |primitive|an integer property| | ### dynamic |FieldName |Format |Description|Sources| |--- |--- |--- |--- | |[per-host](#per-host)|[list](#per-host) | | | |[default](#default) |[nested](#default)| | | ### per-host |FieldName |Format |Description |Sources| |--- |--- |--- |--- | |url |primitive |a text property| | |[dynamic](#dynamic-2)|[all-of](#dynamic-2)| | | ### dynamic |FieldName|Format |Description |Sources| |--- |--- |--- |--- | |minimum |primitive|an integer property| | |maximum |primitive|an integer property| | |ttl |primitive|a duration property| | ### default |FieldName |Format |Description|Sources| |--- |--- |--- |--- | |[dynamic](#defaultdynamic)|[all-of](#defaultdynamic)| | | ### default.dynamic |FieldName|Format |Description |Sources| |--- |--- |--- |--- | |minimum |primitive|an integer property| | |maximum |primitive|an integer property| | |ttl |primitive|a duration property| | --- ## DNS Resolver Config --- id: dnsresolver-config title: zio.http.DnsResolver.Config sidebar_label: DnsResolver.Config --- ## Configuration Details |FieldName|Format |Description|Sources| |--- |--- |--- |--- | | |[all-of](#field-descriptions)| | | ### Field Descriptions |FieldName|Format |Description|Sources| |--- |--- |--- |--- | | |[any-one-of](#field-descriptions-7)| | | | |[any-one-of](#field-descriptions-6)| | | | |[any-one-of](#field-descriptions-5)| | | | |[any-one-of](#field-descriptions-4)| | | | |[any-one-of](#field-descriptions-3)| | | | |[any-one-of](#field-descriptions-2)| | | | |[any-one-of](#field-descriptions-1)| | | ### Field Descriptions |FieldName|Format |Description |Sources| |--- |--- |--- |--- | |ttl |primitive|a duration property| | | |primitive|a constant property| | ### Field Descriptions |FieldName |Format |Description |Sources| |--- |--- |--- |--- | |unknown-host-ttl|primitive|a duration property| | | |primitive|a constant property| | ### Field Descriptions |FieldName|Format |Description |Sources| |--- |--- |--- |--- | |max-count|primitive|an integer property| | | |primitive|a constant property| | ### Field Descriptions |FieldName |Format |Description |Sources| |--- |--- |--- |--- | |max-concurrent-resolutions|primitive|an integer property| | | |primitive|a constant property| | ### Field Descriptions |FieldName |Format |Description |Sources| |--- |--- |--- |--- | |expire-action|primitive|a text property | | | |primitive|a constant property| | ### Field Descriptions |FieldName |Format |Description |Sources| |--- |--- |--- |--- | |refresh-rate|primitive|a duration property| | | |primitive|a constant property| | ### Field Descriptions |FieldName |Format |Description |Sources| |--- |--- |--- |--- | |max-retries|primitive|an integer property| | | |primitive|a constant property| | --- ## Introduction This section describes structure of `Config` objects supported by ZIO HTTP. For usage instructions and examples see the [How to Integrate with ZIO Config](../../guides/integrate-with-zio-config.md) guide. --- ## Netty Config --- id: netty-nettyconfig title: zio.http.netty.NettyConfig sidebar_label: netty.NettyConfig --- ## Configuration Details |FieldName|Format |Description|Sources| |--- |--- |--- |--- | | |[all-of](#field-descriptions)| | | ### Field Descriptions |FieldName |Format |Description|Sources| |--- |--- |--- |--- | | |[any-one-of](#field-descriptions-2)| | | | |[any-one-of](#field-descriptions-1)| | | |[boss-group](#boss-group)|[all-of](#boss-group) | | | ### Field Descriptions |FieldName |Format |Description |Sources| |--- |--- |--- |--- | |leak-detection-level|primitive|a text property | | | |primitive|a constant property| | ### Field Descriptions |FieldName |Format |Description|Sources| |--- |--- |--- |--- | |[worker-group](#worker-group)|[all-of](#worker-group) | | | | |[all-of](#field-descriptions-3)| | | ### worker-group |FieldName|Format |Description|Sources| |--- |--- |--- |--- | | |[any-one-of](#field-descriptions-7)| | | | |[any-one-of](#field-descriptions-6)| | | | |[any-one-of](#field-descriptions-5)| | | | |[any-one-of](#field-descriptions-4)| | | ### Field Descriptions |FieldName |Format |Description |Sources| |--- |--- |--- |--- | |channel-type|primitive|a text property | | | |primitive|a constant property| | ### Field Descriptions |FieldName |Format |Description |Sources| |--- |--- |--- |--- | |max-threads|primitive|an integer property| | | |primitive|a constant property| | ### Field Descriptions |FieldName |Format |Description |Sources| |--- |--- |--- |--- | |shutdown-quiet-period|primitive|a duration property| | | |primitive|a constant property| | ### Field Descriptions |FieldName |Format |Description |Sources| |--- |--- |--- |--- | |shutdown-timeout|primitive|a duration property| | | |primitive|a constant property| | ### Field Descriptions |FieldName|Format |Description|Sources| |--- |--- |--- |--- | | |[any-one-of](#field-descriptions-7)| | | | |[any-one-of](#field-descriptions-6)| | | | |[any-one-of](#field-descriptions-5)| | | | |[any-one-of](#field-descriptions-4)| | | ### Field Descriptions |FieldName |Format |Description |Sources| |--- |--- |--- |--- | |channel-type|primitive|a text property | | | |primitive|a constant property| | ### Field Descriptions |FieldName |Format |Description |Sources| |--- |--- |--- |--- | |max-threads|primitive|an integer property| | | |primitive|a constant property| | ### Field Descriptions |FieldName |Format |Description |Sources| |--- |--- |--- |--- | |shutdown-quiet-period|primitive|a duration property| | | |primitive|a constant property| | ### Field Descriptions |FieldName |Format |Description |Sources| |--- |--- |--- |--- | |shutdown-timeout|primitive|a duration property| | | |primitive|a constant property| | ### boss-group |FieldName|Format |Description|Sources| |--- |--- |--- |--- | | |[any-one-of](#field-descriptions-6)| | | | |[any-one-of](#field-descriptions-5)| | | | |[any-one-of](#field-descriptions-4)| | | | |[any-one-of](#field-descriptions-3)| | | ### Field Descriptions |FieldName |Format |Description |Sources| |--- |--- |--- |--- | |channel-type|primitive|a text property | | | |primitive|a constant property| | ### Field Descriptions |FieldName |Format |Description |Sources| |--- |--- |--- |--- | |max-threads|primitive|an integer property| | | |primitive|a constant property| | ### Field Descriptions |FieldName |Format |Description |Sources| |--- |--- |--- |--- | |shutdown-quiet-period|primitive|a duration property| | | |primitive|a constant property| | ### Field Descriptions |FieldName |Format |Description |Sources| |--- |--- |--- |--- | |shutdown-timeout|primitive|a duration property| | | |primitive|a constant property| | --- ## OpenAPI Config --- id: gen-openapi-config title: zio.http.gen.openapi.Config sidebar_label: gen.openapi.Config --- ## Configuration Details |FieldName|Format |Description|Sources| |--- |--- |--- |--- | | |[all-of](#field-descriptions)| | | ### Field Descriptions |FieldName |Format |Description |Sources| |--- |--- |--- |--- | | |[any-one-of](#field-descriptions-2)| | | | |[any-one-of](#field-descriptions-1)| | | |[fields-normalization](#fields-normalization)|[all-of](#fields-normalization) | | | |string-format-types |map |a text property| | ### Field Descriptions |FieldName |Format |Description |Sources| |--- |--- |--- |--- | |common-fields-on-super-type|primitive|a boolean property | | | |primitive|a constant property| | ### Field Descriptions |FieldName |Format |Description |Sources| |--- |--- |--- |--- | |generate-safe-type-aliases|primitive|a boolean property | | | |primitive|a constant property| | ### fields-normalization |FieldName|Format |Description|Sources| |--- |--- |--- |--- | | |[any-one-of](#field-descriptions-4)| | | | |[any-one-of](#field-descriptions-3)| | | ### Field Descriptions |FieldName|Format |Description |Sources| |--- |--- |--- |--- | |enabled |primitive|a boolean property | | | |primitive|a constant property| | ### Field Descriptions |FieldName |Format |Description |Sources| |--- |--- |--- |--- | |special-replacements|map |a text property | | | |primitive|a constant property| | --- ## Server Config --- id: server-config title: zio.http.Server.Config sidebar_label: Server.Config --- ## Configuration Details |FieldName|Format |Description|Sources| |--- |--- |--- |--- | | |[all-of](#field-descriptions)| | | ### Field Descriptions |FieldName |Format |Description |Sources| |--- |--- |--- |--- | |behaviour |primitive |a text property| | |[data](#data) |[any-one-of](#data) | | | |provider |primitive |a text property| | |binding-host |primitive |a text property| | | |[any-one-of](#field-descriptions-13)| | | | |[any-one-of](#field-descriptions-12)| | | | |[any-one-of](#field-descriptions-11)| | | | |[any-one-of](#field-descriptions-10)| | | |[response-compression](#response-compression)|[all-of](#response-compression) | | | | |[any-one-of](#field-descriptions-9) | | | | |[any-one-of](#field-descriptions-8) | | | | |[any-one-of](#field-descriptions-7) | | | | |[any-one-of](#field-descriptions-6) | | | | |[any-one-of](#field-descriptions-5) | | | | |[any-one-of](#field-descriptions-4) | | | | |[any-one-of](#field-descriptions-3) | | | | |[any-one-of](#field-descriptions-2) | | | | |[any-one-of](#field-descriptions-1) | | | ### data |FieldName|Format |Description |Sources| |--- |--- |--- |--- | | |primitive |a text property| | | |[all-of](#field-descriptions-15)| | | | |[all-of](#field-descriptions-14)| | | ### Field Descriptions |FieldName |Format |Description |Sources| |--- |--- |--- |--- | |certPath |primitive|a text property| | |keyPath |primitive|a text property| | |trust-cert-collection-path|primitive|a text property| | ### Field Descriptions |FieldName |Format |Description |Sources| |--- |--- |--- |--- | |cert-resource |primitive|a text property| | |key-resource |primitive|a text property| | |trust-cert-collection-resource|primitive|a text property| | ### Field Descriptions |FieldName |Format |Description |Sources| |--- |--- |--- |--- | |binding-port|primitive|an integer property| | | |primitive|a constant property| | ### Field Descriptions |FieldName |Format |Description |Sources| |--- |--- |--- |--- | |accept-continue|primitive|a boolean property | | | |primitive|a constant property| | ### Field Descriptions |FieldName |Format |Description |Sources| |--- |--- |--- |--- | |keep-alive|primitive|a boolean property | | | |primitive|a constant property| | ### Field Descriptions |FieldName |Format |Description |Sources| |--- |--- |--- |--- | |request-decompression|primitive|a text property | | | |primitive|a constant property| | ### response-compression |FieldName |Format |Description|Sources| |--- |--- |--- |--- | | |[any-one-of](#field-descriptions-14)| | | |[options](#options)|[list](#options) | | | ### Field Descriptions |FieldName |Format |Description |Sources| |--- |--- |--- |--- | |content-threshold|primitive|an integer property| | | |primitive|a constant property| | ### options |FieldName|Format |Description |Sources| |--- |--- |--- |--- | | |[any-one-of](#field-descriptions-20)| | | | |[any-one-of](#field-descriptions-19)| | | | |[any-one-of](#field-descriptions-18)| | | | |[any-one-of](#field-descriptions-17)| | | | |[any-one-of](#field-descriptions-16)| | | | |[any-one-of](#field-descriptions-15)| | | |type |primitive |a text property| | ### Field Descriptions |FieldName|Format |Description |Sources| |--- |--- |--- |--- | |level |primitive|an integer property| | | |primitive|a constant property| | ### Field Descriptions |FieldName|Format |Description |Sources| |--- |--- |--- |--- | |bits |primitive|an integer property| | | |primitive|a constant property| | ### Field Descriptions |FieldName|Format |Description |Sources| |--- |--- |--- |--- | |mem |primitive|an integer property| | | |primitive|a constant property| | ### Field Descriptions |FieldName|Format |Description |Sources| |--- |--- |--- |--- | |quantity |primitive|an integer property| | | |primitive|a constant property| | ### Field Descriptions |FieldName|Format |Description |Sources| |--- |--- |--- |--- | |lgwin |primitive|an integer property| | | |primitive|a constant property| | ### Field Descriptions |FieldName|Format |Description |Sources| |--- |--- |--- |--- | |mode |primitive|a text property | | | |primitive|a constant property| | ### Field Descriptions |FieldName |Format |Description |Sources| |--- |--- |--- |--- | |[request-streaming](#request-streaming)|[all-of](#request-streaming)| | | | |primitive |a constant property| | ### request-streaming |FieldName|Format |Description|Sources| |--- |--- |--- |--- | | |[any-one-of](#field-descriptions-16)| | | | |[any-one-of](#field-descriptions-15)| | | ### Field Descriptions |FieldName|Format |Description |Sources| |--- |--- |--- |--- | |enabled |primitive|a boolean property | | | |primitive|a constant property| | ### Field Descriptions |FieldName |Format |Description |Sources| |--- |--- |--- |--- | |maximum-content-length|primitive|an integer property| | | |primitive|a constant property| | ### Field Descriptions |FieldName |Format |Description |Sources| |--- |--- |--- |--- | |max-initial-line-length|primitive|an integer property| | | |primitive|a constant property| | ### Field Descriptions |FieldName |Format |Description |Sources| |--- |--- |--- |--- | |max-header-size|primitive|an integer property| | | |primitive|a constant property| | ### Field Descriptions |FieldName |Format |Description |Sources| |--- |--- |--- |--- | |log-warning-on-fatal-error|primitive|a boolean property | | | |primitive|a constant property| | ### Field Descriptions |FieldName |Format |Description |Sources| |--- |--- |--- |--- | |graceful-shutdown-timeout|primitive|a duration property| | | |primitive|a constant property| | ### Field Descriptions |FieldName |Format |Description |Sources| |--- |--- |--- |--- | |idle-timeout|primitive|a duration property| | | |primitive|a constant property| | ### Field Descriptions |FieldName |Format |Description |Sources| |--- |--- |--- |--- | |avoid-context-switching|primitive|a boolean property | | | |primitive|a constant property| | ### Field Descriptions |FieldName |Format |Description |Sources| |--- |--- |--- |--- | |so-backlog|primitive|an integer property| | | |primitive|a constant property| | ### Field Descriptions |FieldName |Format |Description |Sources| |--- |--- |--- |--- | |tcp-nodelay|primitive|a boolean property | | | |primitive|a constant property| | --- ## Request-scoped Context with RequestStore **RequestStore** is a fiber-local storage mechanism in ZIO HTTP that allows you to store and retrieve request-scoped data throughout the lifecycle of an HTTP request. It provides a type-safe way to share context across middleware, handlers, and service layers without explicit parameter passing. ## Overview RequestStore uses ZIO's [`FiberRef`](https://zio.dev/reference/state-management/fiberref/) under the hood to ensure that data is isolated per request and automatically cleaned up when the request completes. This makes it ideal for storing contextual information that needs to be accessed at various points during request processing without leaking memory. Automatic cleanup is built-in, so there's no manual cleanup needed—data is cleared when the fiber completes. RequestStore excels at managing request-scoped context throughout your application. A common use case is request context tracking, where you store user IDs, session IDs, timestamps, IP addresses, correlation IDs, trace IDs, and other contextual information extracted from headers or authentication tokens. This makes the data available to all layers of your application without explicit parameter passing. ## API The core API of RequestStore consists of three main functions: ```scala object RequestStore { // Retrieve a value from the store def get[A: Tag]: UIO[Option[A]] // Store a value in the store def set[A: Tag](a: A): UIO[Unit] // Update a value in the store def update[A: Tag](f: Option[A] => A): UIO[Unit] } ``` You can think of `RequestStore` as a type-safe, request-scoped key-value store where the keys are the types of the values you want to store. The `Tag` context bound ensures type safety by requiring a type tag for the stored type, preventing accidental type mismatches. ## Basic Usage Assume you have modeled some request-scoped data as `UserId`: ```scala case class UserId(value: String) ``` When writing authentication middleware, after validating the user, you can store the `UserId` in the `RequestStore`: ```scala def authorizeAndExtractUserId: Header.Authorization => Task[UserId] = ??? val authMiddleware: Middleware[Any] = new Middleware[Any] { override def apply[Env1 <: Any, Err](routes: Routes[Env1, Err]): Routes[Env1, Err] = routes.transform { h => Handler.scoped[Env1] { Handler.fromFunctionZIO { (req: Request) => { for { header <- ZIO.fromOption(req.header(Header.Authorization)) userId <- authorizeAndExtractUserId(header) _ <- RequestStore.set(userId) response <- h(req) } yield response } orElseFail Response.status(Status.Unauthorized) } } } } ``` Whenever you need to access the `UserId` later in the request lifecycle, simply call `RequestStore.get`: ```scala def getProfile(str: UserId): Task[String] = ??? val routes = Routes( Method.GET / "profile" -> handler { (req: Request) => for { userId <- RequestStore.get[UserId].someOrFail(Response.notFound("No user id found")) profile <- getProfile(userId) } yield Response.text(profile) }, ) @@ authMiddleware ``` You can also update existing data in the store using `RequestStore#update`. ## Integration with Other Features RequestStore is used internally by `forwardHeaders` to store headers that should be forwarded to outgoing requests. For example, the following route forwards the `X-Request-Id` header to the downstream service when calling it: ```scala val routes = Routes( Method.GET / "users" -> handler { (req: Request) => for { client <- ZIO.service[Client] // Authorization header is automatically forwarded via RequestStore response <- (client @@ ZClientAspect.forwardHeaders) .batched(Request.get(url"http://user-service/users")) } yield response } ) @@ Middleware.forwardHeaders("X-Request-Id") ``` --- ## Request-scoped Context via ZIO Environment ZIO HTTP provides request-scoped context through ZIO's Environment system, which offers type-safe dependency injection and context propagation. The primary mechanism is `[HandlerAspect](../aop/handler_aspect.md)` with output context (`CtxOut`), not a dedicated `[RequestStore](request-store.md)` API. This approach leverages ZIO's `R` type parameter to pass request-specific data through the middleware stack to handlers. ## Overview Request-scoped context in ZIO HTTP refers to data tied to the lifetime of a single HTTP request that needs to be accessible throughout the request processing pipeline. Common use cases include authentication tokens, user sessions, correlation IDs, and request metadata. ZIO HTTP solves this through `[HandlerAspect](../aop/handler_aspect.md)`, a specialized middleware type that produces typed context values accessible via the ZIO environment. Middleware extracts relevant context from requests and passes it through the `CtxOut` type parameter, which handlers access via `ZIO.service[T]` or `withContext`. The ZIO Environment approach differs fundamentally from the FiberRef-based pattern called [RequestStore](request-store.md). `HandlerAspect` provides compile-time type safety: the context requirement appears explicitly in handler type signatures, ensuring all dependencies are satisfied before the application compiles. This prevents entire classes of runtime errors where missing context would only be discovered during execution. ## HandlerAspect `HandlerAspect` is ZIO HTTP's middleware abstraction that can produce typed context values. Its type signature reveals the key insight: ```scala final case class HandlerAspect[-Env, +CtxOut]( protocol: ProtocolStack[Env, Request, (Request, CtxOut), Response, Response] ) extends Middleware[Env] ``` **The CtxOut type parameter** represents the context produced by middleware. When middleware processes a request, it returns a tuple `(Request, CtxOut)` where CtxOut contains the extracted context. This context then flows through the middleware stack and becomes available to handlers via ZIO's service pattern. When you compose multiple `HandlerAspects`, their contexts combine as tuples: `HandlerAspect[Env, A] ++ HandlerAspect[Env, B]` produces `HandlerAspect[Env, (A, B)]`. This compositional approach allows building complex context from simple middleware components. Please note that the ZIO Environment (the R in `ZIO[R, E, A]`) tracks dependencies at the type level. Every effect declares what **services** or **contexts** it requires to execute. ZIO HTTP extends this pattern through `HandlerAspect`, which provides a **bridge between HTTP middleware and the ZIO Environment system**. ## Generating Context in HandlerAspect The `HandlerAspect.interceptIncomingHandler` API creates middleware that processes incoming requests and produces a context. The handler receives the Request and must return `(Request, CtxOut)` or fail with a Response: ```scala def interceptIncomingHandler[Env, CtxOut]( handler: Handler[Env, Response, Request, (Request, CtxOut)] ): HandlerAspect[Env, CtxOut] ``` For example, the following middleware extracts an Authorization header, authenticates the user, and produces a `User` context. If authentication fails, it returns a 401 Unauthorized response: ```scala def authenticate(header: Header.Authorization): ZIO[UserService, Throwable, User] = ??? val auth: HandlerAspect[UserService, User] = HandlerAspect.interceptIncomingHandler { Handler.fromFunctionZIO[Request] { request => ZIO .fromOption(request.headers.get(Header.Authorization)) .orElseFail(Response.unauthorized("No Authorization header")) .flatMap(authenticate) .map(user => (request, user)) .orElseFail(Response.unauthorized("Invalid token")) } } ``` This middleware has a type of `HandlerAspect[UserService, User]`, meaning it requires a `UserService` in the environment to perform authentication and produces a `User` context for downstream handlers. ## Accessing Context in Handlers Using `ZIO.service` and its variants, handlers can access the context produced by middleware. The important note here is that `ZIO.service` can be used to access both the ZIO environment and the context produced by `HandlerAspect`: ```scala val greetRoute: Route[UserService, Nothing] = Method.GET / "greet" -> handler { (_: Request) => ZIO.serviceWith[User] { user => Response.text(s"Hello, $user!") } } @@ auth ``` This handler is of type `Handler[User & UserService, Nothing, Request, Response]`, meaning it requires a `User` and `UserService` in the environment. Let's take a closer look at the type signature of `Handler`: ```scala Handler[-R, +Err, -In, +Out] // R: Environment/context required // Err: Error type // In: Input type (typically Request) // Out: Output type (typically Response) ``` The first type parameter `R` represents the environment or context required by the handler. This can be either a service that can be provided via ZLayer or a context produced by `HandlerAspect`. In this example, the `User` is a request-scoped context produced by the `auth` middleware, while `UserService` is a service that can be provided via ZLayer in upper layers. Therefore, the handler requires both `User` and `UserService` in its environment. Since the handler is wrapped with the `auth` middleware, it can access the `User` context produced by the `auth` middleware, which has a type of `HandlerAspect[UserService, User]`. By applying the middleware to the handler using the `@@` operator, the `User` context is provided to the handler, and so the handler type becomes `Handler[UserService, Nothing, Request, Response]`, meaning it only requires `UserService` from the environment. Instead of `ZIO.service`, we can use the helper method `withContext` to access the context: ```scala val greetRoute: Route[UserService, Nothing] = Method.GET / "greet" -> handler { (_: Request) => withContext { (user: User) => Response.text(s"Hello, $user!") } } @@ auth ``` ## Request Context Alongside Environmental Services Inside Handler A curious reader might wonder what happens if the handler also requires a service from ZLayer. How can we combine both request context and application services in the same handler? The answer is that we treat them the same way - both are part of the ZIO environment, but the difference is when and who provides them. So in previous examples, the `User` context is provided by the `auth` middleware, while the `UserService` will be provided later. The same applies to any other service that the handler might require. Let's see what happens when, other than the `auth` middleware, the handler also requires a service from the environment. For example, assume we have a `GreetingService` that generates personalized greetings based on the user information and the current time of day: Now we have to use the environment for `User`, `UserService`, and `GreetingService`. The `User` is a context produced by the `auth` middleware, while the `UserService` and `GreetingService` are services provided via ZLayer in upper layers. The handler can access both the `User` context and the `GreetingService` service using `ZIO.service`: ```scala val greetRoute: Route[UserService & GreetingService, Nothing] = Method.GET / "greet" -> handler(ZIO.service[GreetingService]).flatMap { greetingService => handler { ZIO.serviceWithZIO[User] { user => greetingService.greet(user).map(Response.text(_)) } } @@ auth } ``` In this example, the handler has a type of `Handler[UserService & GreetingService, Response, Request, Response]`, meaning it requires both `UserService` and `GreetingService` from the environment. The `User` context is already provided by the `auth` middleware, while the provision of `UserService` and `GreetingService` is deferred to upper layers when serving the application. Again, we can simplify the handler using `withContext`: ```scala val greetRoute: Route[UserService & GreetingService, Nothing] = Method.GET / "greet" -> handler(ZIO.service[GreetingService]).flatMap { greetingService => handler { withContext { (user: User) => greetingService.greet(user).map(Response.text(_)) } } @@ auth } ``` ## Composing Multiple Contexts When multiple middleware components provide context, their contexts compose as tuples: ```scala val authAspect: HandlerAspect[Any, User] = ??? val requestIdAspect: HandlerAspect[Any, String] = ??? val metricsAspect: HandlerAspect[Any, MetricsContext] = ??? // Composed aspect has tuple type val composedAspect: HandlerAspect[Any, (User, String, MetricsContext)] = authAspect ++ requestIdAspect ++ metricsAspect // Handler receives all contexts val myHandler: Handler[(User, String, MetricsContext), Nothing, Request, Response] = handler { (_: Request) => ZIO.service[(User, String, MetricsContext)].map { case (user, requestId, metrics) => Response.text(s"User: ${user.name}, RequestID: $requestId, metrics: $metrics") } } val exampleRoute = Method.GET / "example" -> myHandler @@ composedAspect ``` Also, we can use `withContext`: ```scala val myHandler: Handler[User & String & MetricsContext, Nothing, Request, Response] = handler { (_: Request) => withContext { (user: User, requestId: String, metrics: MetricsContext) => Response.text(s"User: ${user.name}, RequestID: $requestId, metrics: $metrics") } } ``` --- ## Datastar HTML Attributes The `zio-http-datastar-sdk` provides extensions to the templating module that allow you to easily add type-safe Datastar attributes to your HTML elements: | ZIO HTTP Attribute | Datastar HTML Attribute | Description | |---------------------------|-------------------------------|--------------------------------------------------------------------------------------------------------------| | `dataAttr` | `data-attr` | Set arbitrary attributes. [↗](https://data-star.dev/reference/attributes#data-attr) | | `dataBind` | `data-bind` | Binds signal name to input/select/textarea values. [↗](https://data-star.dev/reference/attributes#data-bind) | | `dataClass` | `data-class` | Toggle classes. [↗](https://data-star.dev/reference/attributes#data-class) | | `dataComputed` | `data-computed` | Computed values from expressions. [↗](https://data-star.dev/reference/attributes#data-computed) | | `dataEffect` | `data-effect` | Side effects from expressions. [↗](https://data-star.dev/reference/attributes#data-effect) | | `dataIgnore` | `data-ignore` | Ignore this element and its children. [↗](https://data-star.dev/reference/attributes#data-ignore) | | `dataIgnoreSelf` | `data-ignore__self` | Ignore only this element, not children. [↗](https://data-star.dev/reference/attributes#data-ignore) | | `dataIgnoreMorph` | `data-ignore-morph` | Ignore morphing for this element. [↗](https://data-star.dev/reference/attributes#data-ignore-morph) | | `dataIndicator` | `data-indicator` | Loading indicator. [↗](https://data-star.dev/reference/attributes#data-indicator) | | `dataJsonSignals` | `data-json-signals` | JSON signal declarations. [↗](https://data-star.dev/reference/attributes#data-json-signals) | | `dataOn` | `data-on` | Event listeners (click, input, etc.). [↗](https://data-star.dev/reference/attributes#data-on) | | `dataOnIntersect` | `data-on-intersect` | Execute when element intersects viewport. [↗](https://data-star.dev/reference/attributes#data-on-intersect) | | `dataOnInterval` | `data-on-interval` | Execute on interval. [↗](https://data-star.dev/reference/attributes#data-on-interval) | | `dataOnLoad` | `data-init` (deprecated) | Execute when element loads (generates `data-init`). [↗](https://data-star.dev/reference/attributes#data-on-load) | | `dataOnSignalPatch` | `data-on-signal-patch` | Execute when signal patches. [↗](https://data-star.dev/reference/attributes#data-on-signal-patch) | | `dataOnSignalPatchFilter` | `data-on-signal-patch-filter` | Filter signal patch events. [↗](https://data-star.dev/reference/attributes#data-on-signal-patch-filter) | | `dataPreserveAttr` | `data-preserve-attr` | Preserve attributes during morphing. [↗](https://data-star.dev/reference/attributes#data-preserve-attr) | | `dataRef` | `data-ref` | Assign a local reference. [↗](https://data-star.dev/reference/attributes#data-ref) | | `dataShow` | `data-show` | Show element when expression is truthy. [↗](https://data-star.dev/reference/attributes#data-show) | | `dataSignals` | `data-signals` | Declare/expose signals. [↗](https://data-star.dev/reference/attributes#data-signals) | | `dataStyle("styleName")` | `data-style` | Sets inline style. [↗](https://data-star.dev/reference/attributes#data-style) | | `dataText` | `data-text` | Sets element text content from expression. [↗](https://data-star.dev/reference/attributes#data-text) | ## Using Attributes Most of these attributes have a `:=` dsl method that takes a `Js` value representing the [Datastar expression](https://data-star.dev/guide/datastar_expressions) in which you can use signals, JavaScript code, or special `@` commands to interact with the server. You can use the `Js` helper to create these expressions or use the `js` interpolator which has compile-time checking of the expressions. For example, you can use the `dataOnLoad` attribute to trigger a server request when the page loads: ```scala body( dataOnLoad := js"@get('/hello-world')", dataOn.load := Endpoint(Method.GET / "hello-world").out[String].datastarRequest(()), div( className := "container", h1("Hello World Example"), div(id("message")) ) ) ``` If you want a type-safe DSL, we recommend using the `Endpoint` API instead of a JavaScript expression when using the `dataOn` attribute to define a Datastar server request: ```scala body( dataOn.load := Endpoint(Method.GET / "hello-world").out[String].datastarRequest(()), div( className := "container", h1("Hello World Example"), div(id("message")) ) ) ``` The `dataOn` attribute has several event listeners that you can use to handle different events on elements. For example, the `dataOnLoad` can be written as `dataOn.load`; there are many other events available, such as `click`, `input`, `submit`, `change`, `focus`, `blur`, etc. Each of the attributes may have a couple of modifiers, about which you can learn in their respective sections in the [Datastar documentation](https://data-star.dev/reference/attributes). They are all modeled as a type-safe DSL in the `zio-http-datastar-sdk` module. For example, you can use the `debounce` modifier on the `dataOn.input` attribute to debounce input events: ```scala dataOn.input.debounce(300.millis) := Js("@get('/search?q=' + $query)") // datastar equivalent: ``` ## Working with Signals Another important attribute is `dataSignals`, which allows you to declare signals that can be used in the Datastar expressions. You can declare a signal using the `dataSignals` attribute as follows: ```scala val $currentTime = Signal[String]("currentTime") dataSignals($currentTime) := js"'--:--:--'" ``` This declares a signal named `currentTime` of type `String` with an initial value of `00:00:00`. You can then use this signal in other Datastar attributes. For example, you can use `dataText` to display the current time: ```scala span( dataSignals($currentTime) := js"'--:--:--'", dataText := $currentTime, dataOn.load := Endpoint(Method.GET / "server-time").out[String].datastarRequest(()), ) ``` In this example, the `dataText` attribute binds the text content of the `span` element to the `currentTime` signal, and the `dataOn.load` attribute triggers a server request to update the signal when the page loads. Server-side updates to signals are sent using the [`ServerSentEventGenerator#patchSignals`](./server-api.md#patching-signals) method. --- ## Datastar SDK Examples ## Running the Examples All code from this reference is available as runnable examples in the `zio-http-example` module. **1. Clone the repository and navigate to the project:** ```bash git clone https://github.com/zio/zio-http.git cd zio-http ``` **2. Run individual examples with sbt:** ### Simple Hello World Example This example demonstrates the most basic use of Datastar with ZIO HTTP by streaming a "Hello, world!" message to the browser. It's an excellent starting point for understanding how Server-Sent Events work with Datastar: ```scala title="zio-http-example/src/main/scala/example/datastar/SimpleHelloWorldExample.scala" package example.datastar object SimpleHelloWorldExample extends ZIOAppDefault { val message = "Hello, world!" val routes: Routes[Any, Response] = Routes( // Main page route Method.GET / Root -> handler { Response( headers = Headers( Header.ContentType(MediaType.text.html), ), body = Body.fromCharSequence(indexPage.render), ) }, Method.GET / "hello-world" -> events { handler { ZIO.foreachDiscard(message.indices) { i => for { _ <- ServerSentEventGenerator.executeScript(js"console.log('Sending substring(0, ${i + 1})')") _ <- ServerSentEventGenerator.patchElements(div(id("message"), message.substring(0, i + 1))) _ <- ZIO.sleep(100.millis) } yield () } } }, ) def indexPage = html( head( meta(charset("UTF-8")), meta(name("viewport"), content("width=device-width, initial-scale=1.0")), title("Datastar Hello World - ZIO HTTP Datastar"), datastarScript, style.inlineCss(css), ), body( dataInit := Endpoint(Method.GET / "hello-world").out[String].datastarRequest(()), div( className := "container", h1("Hello World Example"), div(id("message")), ), ), ) override def run: ZIO[Any, Throwable, Unit] = Server .serve(routes) .provide(Server.default) val css = css""" body { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; font-family: system-ui, -apple-system, sans-serif; font-size: 1.5rem; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); margin: 0; padding: 20px; } .container { text-align: center; background: white; border-radius: 10px; padding: 30px; box-shadow: 0 10px 40px rgba(0,0,0,0.2); max-width: 600px; width: 100%; } h1 { font-size: 3rem; color: #333; margin-bottom: 30px; margin-top: 0; } #message { font-size: 2rem; margin-top: 2rem; padding: 20px; background: #f0f4ff; border-left: 4px solid #667eea; border-radius: 6px; color: #333; min-height: 50px; display: flex; align-items: center; justify-content: center; } """ } ``` **How it works:** The page uses the `dataOn.load` attribute to trigger a GET request to `/hello-world` as soon as the page loads. The server responds with a stream of SSE events, where each event patches a div element with `id="message"` to display progressively more characters of the message. On the server side, the handler iterates through each character index of "Hello, world!" and for each iteration: 1. Executes a console.log script on the client using `ServerSentEventGenerator.executeScript` to log the current character index 2. Patches the `#message` div with a substring containing all characters up to the current index using `ServerSentEventGenerator.patchElements` 3. Waits 100 milliseconds before processing the next character This creates a typewriter effect where the message appears one character at a time. The entire interaction happens without any custom JavaScript code - just declarative HTML attributes and server-side streaming. ### Hello World with Custom Delay This example builds upon the Simple Hello World example by adding user control over the animation speed, demonstrating how to use Datastar signals for bidirectional communication between client and server: ```scala title="zio-http-example/src/main/scala/example/datastar/HelloWorldWithCustomDelayExample.scala" package example.datastar case class Delay(delay: Int) object Delay { implicit val jsonCodec: JsonCodec[Delay] = DeriveJsonCodec.gen } object HelloWorldWithCustomDelayExample extends ZIOAppDefault { val message = "Hello, world!" val routes: Routes[Any, Response] = Routes( // Main page route Method.GET / Root -> handler { Response( headers = Headers( Header.ContentType(MediaType.text.html), ), body = Body.fromCharSequence(indexPage.render), ) }, Method.GET / "hello-world" -> events { handler { (request: Request) => val delay = request.url.queryParams .getAll("datastar") .headOption .flatMap { s => Delay.jsonCodec.decodeJson(s).toOption } .getOrElse(Delay(100)) ZIO.foreachDiscard(message.indices) { i => for { _ <- ServerSentEventGenerator.executeScript(js"console.log('Sending substring(0, $i)')") _ <- ServerSentEventGenerator.patchElements( div(id("message"), message.substring(0, i + 1)), ) _ <- ZIO.sleep(delay.delay.millis) } yield () } } }, ) def indexPage = html( head( meta(charset("UTF-8")), meta(name("viewport"), content("width=device-width, initial-scale=1.0")), title("Datastar Hello World - ZIO HTTP Datastar"), datastarScript, style.inlineCss(css), ), body( div( className := "container", h1("Hello World Example"), { val $delay = Signal[Int]("delay") div( dataSignals($delay) := js"100", label("Delay (ms): ", `for` := "delay"), input(dataBind($delay.name), name := "delay", `type` := "number", step := "100"), ) }, button(dataOn.click := Endpoint(Method.GET / "hello-world").out[String].datastarRequest(()))( "Start Animation", ), div(id("message")), ), ), ) override def run: ZIO[Any, Throwable, Unit] = Server .serve(routes) .provide(Server.default) val css = css""" body { display: flex; flex-direction: row; align-items: center; justify-content: center; font-family: system-ui, -apple-system, sans-serif; font-size: 1.5rem; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); margin: 0; padding: 20px; } .container { text-align: center; background: white; border-radius: 10px; padding: 30px; box-shadow: 0 10px 40px rgba(0,0,0,0.2); } h1 { font-size: 3rem; color: #333; margin-bottom: 30px; } label { display: inline-block; margin-right: 10px; color: #555; font-weight: 500; } input[type="number"] { padding: 8px 12px; border: 2px solid #e0e0e0; border-radius: 6px; font-size: 1rem; transition: border-color 0.3s; } input[type="number"]:focus { outline: none; border-color: #667eea; } button { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; font-size: 1.5rem; padding: 1rem 2rem; margin-top: 2rem; border: none; border-radius: 6px; font-weight: 600; transition: transform 0.2s, box-shadow 0.2s; } button:hover { transform: translateY(-2px); box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4); } button:active { transform: translateY(0); } #message { font-size: 2rem; margin-top: 2rem; padding: 20px; background: #f0f4ff; border-left: 4px solid #667eea; border-radius: 6px; color: #333; min-height: 50px; } #message:empty { display: none; } """ } ``` **How it works:** The page declares a signal called `delay` using `dataSignals(Signal[Int]("delay"))` with an initial value of 100 milliseconds. This signal is bound to a number input field via `dataBind("delay")`, which means any changes to the input automatically update the `$delay` signal value. A button with `dataOn.click := Js("@get('/hello-world')")` triggers the animation when clicked. The key difference from the simple example is how the server extracts and uses the delay value. When the button is clicked, Datastar automatically includes all current signal values in a query parameter named `datastar`. The server extracts this parameter, decodes it as JSON to get the `Delay` case class, and uses that value to control the sleep duration between character updates. Users can adjust the delay value and restart the animation to see it play at different speeds, all without writing any JavaScript code. ### Server Time Example This example showcases real-time, server-pushed updates by streaming the current server time to the browser every second. It demonstrates a common pattern for live dashboards and monitoring applications. ```scala title="zio-http-example/src/main/scala/example/datastar/ServerTimeExample.scala" package example.datastar object ServerTimeExample extends ZIOAppDefault { val timeHTML = html( head( title("Server Time - Datastar"), datastarScript, style.inlineCss( css""" body { display: flex; justify-content: center; align-items: center; min-height: 100vh; font-family: system-ui, -apple-system, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; } .container { text-align: center; background: rgba(255, 255, 255, 0.1); padding: 3rem; border-radius: 1rem; backdrop-filter: blur(10px); } h1 { font-size: 2.5rem; margin-bottom: 2rem; } .time-display { font-size: 4rem; font-weight: bold; margin: 2rem 0; font-family: 'Courier New', monospace; } button { font-size: 1.2rem; padding: 1rem 2rem; margin: 0.5rem; cursor: pointer; border: none; border-radius: 0.5rem; background: white; color: #667eea; font-weight: bold; transition: transform 0.2s; } button:hover { transform: scale(1.05); } button:disabled { opacity: 0.5; cursor: not-allowed; } .status { margin-top: 1rem; font-size: 1.2rem; opacity: 0.8; } """.stripMargin, ), ), body( div(className := "container")( h1("Live Server Time"), { val $currentTime = Signal[String]("currentTime") span( dataSignals($currentTime) := js"'--:--:--'", dataText := $currentTime, className := "time-display", dataOn.load := Endpoint(Method.GET / "server-time").out[String].datastarRequest(()), ) }, ), ), ) // Time formatter val timeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss") // Server time streaming endpoint val serverTimeHandler = events { handler { ZIO.clock .flatMap(_.currentDateTime) .map(_.toLocalTime.format(timeFormatter)) .flatMap { currentTime => ZIO.logInfo(s"Sending time: $currentTime") *> ServerSentEventGenerator.patchSignals( s"{ 'currentTime': '$currentTime' }", PatchSignalOptions(retryDuration = 5.seconds), ) } .schedule(Schedule.spaced(1.second)) .unit } } val routes = Routes( Method.GET / Root -> event(handler((_: Request) => DatastarEvent.patchElements(timeHTML))), Method.GET / "server-time" -> serverTimeHandler, ) override def run = ZIO.logInfo("Starting server on http://localhost:8080") *> Server.serve(routes).provide(Server.default) } ``` **How it works:** The page displays a time value using `dataText := $currentTime)`, which binds the text content of a span element to the `currentTime` signal. The signal is declared with `dataSignals(Signal[String]("currentTime"))` and initialized to an empty string. When the page loads (`dataOn.load := Js("@get('/server-time')")`), it establishes an SSE connection to the `/server-time` endpoint. The server handler uses ZIO's scheduling capabilities to create a repeating effect that runs every second. Each second, the server: 1. Gets the current time from the clock 2. Formats it as "HH:mm:ss" 3. Patches the `currentTime` signal with the new value using `patchSignals` The `PatchSignalOptions` configures a 5-second retry duration, meaning if the connection drops, the client will attempt to reconnect after 5 seconds. The signal patching sends JSON that updates only the specified signals without requiring a full page refresh. ### Greeting Form Example This example demonstrates single-shot (non-streaming) responses using traditional form submissions, showing how Datastar handles HTTP transactions alongside SSE streaming. ```scala title="zio-http-example/src/main/scala/example/datastar/GreetingFormExample.scala" package example.datastar object GreetingFormExample extends ZIOAppDefault { val routes: Routes[Any, Response] = Routes( Method.GET / "" -> event(handler((_: Request) => DatastarEvent.patchElements(indexPage))), Method.GET / "greet" -> event { handler { (req: Request) => DatastarEvent.patchElements( div( id("greeting"), p(s"Hello ${req.queryParam("name").getOrElse("Guest")}"), ), ) } } @@ Middleware.debug, ) def indexPage = html( head( meta(charset("UTF-8")), meta(name("viewport"), content("width=device-width, initial-scale=1.0")), title("Greeting Form - ZIO HTTP Datastar"), datastarScript, style.inlineCss(css), ), body( div( className := "container", h1("👋 Greeting Form 👋"), form( id("greetingForm"), dataOn.submit := js"@get('/greet', {contentType: 'form'})", label( `for`("name"), "What's your name?", ), input( `type`("text"), id("name"), name("name"), placeholder("Enter your name!"), required, autofocus, ), button( `type`("submit"), "Greet me!", ), ), div(id("greeting")), ), ), ) val css = """ body { font-family: system-ui, -apple-system, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; } .container { background: white; border-radius: 10px; padding: 30px; box-shadow: 0 10px 40px rgba(0,0,0,0.2); } h1 { color: #333; margin-bottom: 30px; } label { display: block; margin-bottom: 8px; color: #555; font-weight: 500; } input[type="text"] { width: 100%; padding: 12px; margin-bottom: 20px; border: 2px solid #e0e0e0; border-radius: 6px; font-size: 16px; transition: border-color 0.3s; box-sizing: border-box; } input[type="text"]:focus { outline: none; border-color: #667eea; } button { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 12px 30px; border: none; border-radius: 6px; font-size: 16px; font-weight: 600; cursor: pointer; transition: transform 0.2s, box-shadow 0.2s; } button:hover { transform: translateY(-2px); box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4); } button:active { transform: translateY(0); } button:disabled { opacity: 0.6; cursor: not-allowed; } #greeting { margin-top: 30px; padding: 20px; background: #f0f4ff; border-left: 4px solid #667eea; border-radius: 6px; font-size: 18px; color: #333; } #greeting:empty { display: none; } """ override def run: ZIO[Any, Throwable, Unit] = Server .serve(routes) .provide(Server.default) } ``` **How it works:** The page contains a form with an input field for the user's name. The form uses `dataOn.submit := js"@get('/greet', {contentType: 'form'})"` to intercept the submit event and send a GET request with the form data. The `{contentType: 'form'}` option tells Datastar to serialize the form fields as query parameters. Unlike the streaming examples, the server responds with a single HTML fragment (not SSE): ```scala event(handler((_: Request) => DatastarEvent.patchElements(indexPage))) ``` The response is a `text/html` fragment containing a div with `id="greeting"`. Datastar automatically finds the existing `` in the DOM and morphs it with the new content, displaying the personalized greeting. The interaction is smooth and partial—only the greeting div updates, not the entire page. ### Fruit Explorer Example This example demonstrates real-time search with debouncing and view transitions, showcasing advanced Datastar features for building sophisticated interactive UIs. ```scala title="zio-http-example/src/main/scala/example/datastar/FruitExplorerExample.scala" package example.datastar object FruitExplorerExample extends ZIOAppDefault { val routes: Routes[Any, Response] = Routes( // Main page route Method.GET / "" -> handler { Response( headers = Headers( Header.ContentType(MediaType.text.html), ), body = Body.fromCharSequence(indexPage.render), ) }, Method.GET / "search" -> events { handler { (req: Request) => for { searchTerm <- ZIO .succeed( req.url.queryParameters .getAll("q") .headOption, ) results <- search(searchTerm) _ <- ZIO.when(results.isEmpty)( ServerSentEventGenerator.patchElements(div(id("result"), p("No results found."))), ) _ <- ZIO.when(results.nonEmpty) { ServerSentEventGenerator.patchElements(div(id("result"), ol(id("list")))) *> ZIO.foreachDiscard(results) { r => ServerSentEventGenerator .patchElements( li(r), PatchElementOptions( selector = Some(CssSelector.id("list")), mode = ElementPatchMode.Append, useViewTransition = true, ), ) .delay(100.millis) } } } yield () }.orDie } @@ Middleware.debug, ) def indexPage = { html( head( meta(charset("UTF-8")), meta(name("viewport"), content("width=device-width, initial-scale=1.0")), title("Fruit Explorer Example - ZIO HTTP Datastar"), datastarScript, style.inlineCss(css""" ::view-transition-old(root), ::view-transition-new(root) { animation-duration: 0.8s; } ::view-transition-old(root) { animation-timing-function: ease-in-out; } ::view-transition-new(root) { animation-timing-function: ease-in-out; } body { font-family: system-ui, -apple-system, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; } .container { background: white; border-radius: 10px; padding: 30px; box-shadow: 0 10px 40px rgba(0,0,0,0.2); } h1 { color: #333; margin-bottom: 30px; } .form-group { margin-bottom: 20px; } label { display: block; margin-bottom: 8px; color: #555; font-weight: 500; } input[type="text"] { width: 100%; padding: 12px; border: 2px solid #e0e0e0; border-radius: 6px; font-size: 16px; transition: border-color 0.3s; box-sizing: border-box; } input[type="text"]:focus { outline: none; border-color: #667eea; } button { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 12px 30px; border: none; border-radius: 6px; font-size: 16px; font-weight: 600; cursor: pointer; transition: transform 0.2s, box-shadow 0.2s; } button:hover { transform: translateY(-2px); box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4); } button:active { transform: translateY(0); } button:disabled { opacity: 0.6; cursor: not-allowed; } #greeting { margin-top: 30px; padding: 20px; background: #f0f4ff; border-left: 4px solid #667eea; border-radius: 6px; font-size: 18px; color: #333; } #greeting:empty { display: none; } """), ), body( div( className := "container", h1("\uD83D\uDD0E Fruit Explorer \uD83C\uDF47"), { val $query = Signal[String]("query") input( `type` := "text", placeholder := "Search ...", name := "query", dataSignals($query) := "", dataBind($query.name), dataOn.input.debounce(300.millis) := js"@get('/search?q=' + ${$query})", autofocus, ) }, div(id("result")), ), ), ) } def search(term: Option[String]): Task[List[String]] = ZIO.succeed { val data: List[String] = List( "Apple", "Banana", "Orange", "Mango", "Strawberry", "Grape", "Watermelon", "Pineapple", "Peach", "Cherry", "Pear", "Plum", "Kiwi", "Blueberry", "Raspberry", "Blackberry", "Lemon", "Lime", "Grapefruit", "Avocado", "Coconut", "Pomegranate", "Apricot", "Nectarine", "Cantaloupe", "Honeydew", "Fig", "Date", "Persimmon", "Mulberry", "Quince", "Melon", "Greengage", "Barberry", "Bitter Orange", "Sour Cherry", ) if (term.isEmpty) Nil else data.filter(_.toLowerCase.contains(term.get.toLowerCase)) } override def run: ZIO[Any, Throwable, Unit] = Server .serve(routes) .provide(Server.default) } ``` **How it works:** The page contains a single input field with two key attributes: 1. `dataBind("query")` - Binds the input value to a `$query` signal 2. `dataOn.input.debounce(300.millis) := js"@get('/search?q=' + ${$query})"` - Triggers a search request 300ms after the user stops typing The debouncing prevents excessive server requests while typing. Each keystroke updates the `$query` signal, but the search only fires after a 300ms pause, reducing server load and providing a smoother UX. The server handler extracts the search term from the query parameter and filters a fruit list. When results are found, it: 1. First patches an empty results container with `ServerSentEventGenerator.patchElements(div(id("result"), ol(id("list"))))` 2. Then streams each result as a separate list item with a 100ms delay between items: ```scala mdoc:compile-only ServerSentEventGenerator.patchElements( li(r), PatchElementOptions( selector = Some(CssSelector.id("list")), mode = ElementPatchMode.Append, useViewTransition = true ) ).delay(100.millis) ``` The `PatchElementOptions` are particularly interesting here: - `selector = Some(CssSelector.id("list"))` - Targets the specific list element by ID - `mode = ElementPatchMode.Append` - Adds each item to the end of the list rather than replacing it - `useViewTransition = true` - Enables smooth CSS View Transitions API animations The CSS includes view transition rules that create smooth fade-in effects: ```css ::view-transition-old(root), ::view-transition-new(root) { animation-duration: 0.8s; } ``` The result is a highly responsive search experience with beautiful animations, all controlled from the server without complex client-side state management. ### Real-time Chat Example For a more comprehensive example demonstrating multi-client real-time chat with ZIO Hub broadcasting, see the [Real-time Chat with Datastar](../../guides/real-time-chat-with-datastar.md) guide. This example showcases: - Broadcasting messages to multiple connected clients using ZIO Hub - Persistent SSE connections for real-time updates - Two-way signal binding with form inputs - Type-safe request handling with `readSignals[T]` ### Dispatch Event Example This example demonstrates a complete multi-step data processing workflow with real-time progress updates via Server-Sent Events and custom event dispatching to coordinate client-side state changes. It shows how to combine SSE streaming for live feedback with event dispatch for final state coordination. ```scala title="zio-http-example/src/main/scala/example/datastar/DispatchEventCompleteExample.scala" package example.datastar /** * Datastar Dispatch Event — Complete Example with Server-Sent Events * * This complete example demonstrates a data processing workflow where: * 1. Client initiates a long-running task via POST * 2. Server streams SSE updates during processing * 3. Server dispatches a custom event when complete * 4. Client responds to the event with UI updates * * Run with: sbt "zioHttpExample/runMain * example.datastar.DispatchEventCompleteExample" */ object DispatchEventCompleteExample extends ZIOAppDefault { val routes: Routes[Any, Response] = Routes( Method.GET / Root -> event { handler { (_: Request) => DatastarEvent.patchElements(indexPage) } }, Method.POST / "api" / "data-processor" -> events { handler { for { // Send initial log entry _ <- ServerSentEventGenerator.patchElements( div(className := "log-entry", "✓ Processing started"), PatchElementOptions( selector = Some(CssSelector.id("logContainer")), mode = ElementPatchMode.Append, ), ) _ <- ZIO.sleep(500.millis) // Simulate processing step 1 _ <- ServerSentEventGenerator.patchElements( div(className := "log-entry", "✓ Step 1: Validating data"), PatchElementOptions( selector = Some(CssSelector.id("logContainer")), mode = ElementPatchMode.Append, ), ) _ <- ZIO.sleep(800.millis) // Simulate processing step 2 _ <- ServerSentEventGenerator.patchElements( div(className := "log-entry", "✓ Step 2: Processing records"), PatchElementOptions( selector = Some(CssSelector.id("logContainer")), mode = ElementPatchMode.Append, ), ) _ <- ZIO.sleep(800.millis) // Simulate processing step 3 _ <- ServerSentEventGenerator.patchElements( div(className := "log-entry", "✓ Step 3: Generating report"), PatchElementOptions( selector = Some(CssSelector.id("logContainer")), mode = ElementPatchMode.Append, ), ) _ <- ZIO.sleep(800.millis) // Update status box _ <- ServerSentEventGenerator.patchElements( div( id("statusBox"), className := "status-box success", "✓ Processing complete!", ), PatchElementOptions(selector = Some(CssSelector.id("statusBox"))), ) // Dispatch custom event to trigger client-side handler _ <- ServerSentEventGenerator.dispatchEvent( "processingComplete", js"{}", DispatchEventOptions(source = Some(CssSelector.id("startBtn"))), ) // Final log entry _ <- ServerSentEventGenerator.patchElements( div(className := "log-entry success", "✓ All steps completed successfully"), PatchElementOptions( selector = Some(CssSelector.id("logContainer")), mode = ElementPatchMode.Append, ), ) } yield () } }, ) def indexPage = html( head( title("Data Processor with Dispatch Events"), datastarScript, style.inlineCss(css), ), body( div( className := "container", h1("📊 Data Processing with Server Events"), p("Demonstrates server-side event dispatching for coordinating multi-step workflows."), button( "Start Processing", id("startBtn"), dataOn.click := js"this.disabled = true; @post('/api/data-processor'); document.getElementById('statusBox').textContent = 'Processing...';", ), div( id("statusBox"), className := "status-box", "Ready to process", ), div( id("logContainer"), className := "log-container", div(className := "log-entry", "Waiting for processing to start..."), ), dataOn("processingComplete") := js"document.getElementById('startBtn').disabled = false;", ), ), ) val css = css""" body { font-family: system-ui; margin: 40px; background: #f5f5f5; } .container { max-width: 700px; margin: 0 auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); } h1 { color: #1976d2; margin-bottom: 10px; } p { color: #666; margin-bottom: 20px; } button { padding: 12px 24px; font-size: 16px; background: #1976d2; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: 500; } button:disabled { background: #ccc; cursor: not-allowed; } .status-box { margin-top: 20px; padding: 15px; border-left: 4px solid #1976d2; background: #f5f5f5; border-radius: 4px; font-weight: 500; } .status-box.success { border-left-color: #4caf50; background: #e8f5e9; color: #2e7d32; } .log-container { margin-top: 30px; max-height: 300px; overflow-y: auto; border: 1px solid #ddd; border-radius: 4px; background: #fafafa; } .log-entry { padding: 10px 15px; border-bottom: 1px solid #eee; font-family: 'Courier New', monospace; font-size: 13px; color: #333; } .log-entry.success { color: #4caf50; } """ override def run: ZIO[Any, Throwable, Unit] = Server .serve(routes) .provide(Server.default) } ``` ([source](https://github.com/zio/zio-http/blob/main/zio-http-example/src/main/scala/example/datastar/DispatchEventCompleteExample.scala)) ```bash sbt "zioHttpExample/runMain example.datastar.DispatchEventCompleteExample" ``` --- ## Datastar Event Generation Helpers ZIO HTTP provides a set of helpers to generate Datastar events that can be sent to the browser as a response. These helpers make it easy to create the different types of events that Datastar supports. In general, there are two types of events: 1. **Single-shot events**: These events are sent as a single response to the browser. 2. **Streaming events**: These events are sent as a stream of responses to the browser. ## Single-shot Events Single-shot events are those responses that are sent once to the browser using the `text/html` as the content type. In ZIO HTTP, you can create them simply by returning a `Response` with the appropriate content type and body. For example, assume you have written a form that takes a username and submits it to the server as follows: ```scala div( className := "container", h1("👋 Greeting Form 👋"), form( id("greetingForm"), dataOn.submit := js"@get('/greet', {contentType: 'form'})", label(`for`("name"), "What's your name?"), input(`type`("text"), id("name"), name("name"), placeholder("Enter your name!"), required, autofocus), button(`type`("submit"), "Greet me!"), ), div(id("greeting")) ) ``` The server responds with a single-shot event that updates a greeting message with the provided username: ```scala Method.GET / "greet" -> event { handler { (req: Request) => DatastarEvent.patchElements( div( id("greeting"), p(s"Hello ${req.queryParam("name").getOrElse("Guest")}"), ) ) } } ``` If the client submits the form with the name "John", the request would be `GET /greet?name=John` and the response from the server would be: ```http HTTP/1.1 200 Ok content-type: text/html content-length: 42 Hello John ``` The browser receives the response and updates the DOM accordingly using Datastar's built-in patching mechanism. ## Streaming Events Streaming events are those responses that are sent as a stream of events to the browser using the `text/event-stream` as the content type. In ZIO HTTP, you can create them using `ServerSentEventGenerator` to generate the appropriate SSE events. Assume you call the `/hello-world` endpoint that streams a "Hello, World!" message once the page loads: ```scala body( dataOn.load := js"@get('/hello-world')", div( className := "container", h1("Hello World Example"), div(id("message")) ) ) ``` The server responds with a streaming event that sends characters progressively: ```scala val message = "Hello, world!" Method.GET / "hello-world" -> events { handler { ZIO.foreachDiscard(message.indices) { i => for { _ <- ServerSentEventGenerator.patchElements(div(id("message"), message.substring(0, i + 1))) _ <- ZIO.sleep(100.millis) } yield () } } } ``` If the client makes the `GET /hello-world` request, the response from the server would be: ```http HTTP/1.1 200 Ok content-type: text/event-stream connection: keep-alive transfer-encoding: chunked event: datastar-patch-elements data: elements H event: datastar-patch-elements data: elements He event: datastar-patch-elements data: elements Hel .... event: datastar-patch-elements data: elements Hello, world! ``` As the server streams the response, the browser receives each event and updates the DOM accordingly using Datastar's built-in patching mechanism. You can generate and send four types of Datastar SSE events to the client using the `ServerSentEventGenerator`: 1. **Patch Elements into the DOM** using `ServerSentEventGenerator#patchElements` methods 2. **Patch Signals** which updates the values of reactive signals using `ServerSentEventGenerator#patchSignals` methods 3. **Execute Scripts** which run JavaScript code on the client using `ServerSentEventGenerator#executeScript` method 4. **Dispatch Events** which fire custom DOM events on the client using `ServerSentEventGenerator#dispatchEvent` method ## Patching Elements The `ServerSentEventGenerator#patchElements` takes an HTML fragment and sends it to the client to be merged into the DOM. As a second argument, it takes options of type `PatchElementOptions` to specify how the patching should be done: ```scala final case class PatchElementOptions( selector: Option[CssSelector] = None, mode: ElementPatchMode = ElementPatchMode.Outer, useViewTransition: Boolean = false, eventId: Option[String] = None, retryDuration: Duration = 1000.millis, ) ``` 1. The `selector` is an optional CSS selector to specify where in the DOM the patch should be applied. If omitted the id of the returned element is used. 2. The `mode` specifies how the patch should be applied. It has 8 different modes: - **Outer**: Morph entire element, preserving state - **Inner**: Morph inner HTML only, preserving state - **Replace**: Replace entire element, reset state - **Prepend/Append/Before/After**: Insertion modes - **Remove**: Delete element 3. The `useViewTransition` specifies whether to use the [View Transition API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API) for smooth transitions when patching elements. 4. The `eventId` is an optional identifier for the event. 5. The `retryDuration` specifies the duration the client should wait before retrying the connection in case of failure. For example, if we run the following code on the server: ```scala val message = "Hello, world!" ZIO.foreachDiscard(message.indices) { i => for { _ <- ServerSentEventGenerator.patchElements( div(id("message"), message.substring(0, i + 1)), PatchElementOptions( mode = ElementPatchMode.Replace, retryDuration = 5.seconds, eventId = Some(i.toString)), ) _ <- ZIO.sleep(100.millis) } yield () } ``` We will end up sending the following SSE events to the client: ```http event: datastar-patch-elements data: mode replace data: elements H id: 0 retry: 5000 event: datastar-patch-elements data: mode replace data: elements He id: 1 retry: 5000 ... event: datastar-patch-elements data: mode replace data: elements Hello, world! id: 12 retry: 5000 ``` More details about patching elements can be found in the [Datastar documentation](https://data-star.dev/reference/sse_events#datastar-patch-elements). ## Patching Signals The `ServerSentEventGenerator#patchSignals` is used to update the values of reactive signals on the client. As a second argument, it takes options of type `PatchSignalOptions` to specify how the patching should be done: ```scala final case class PatchSignalOptions( onlyIfMissing: Boolean = false, eventId: Option[String] = None, retryDuration: Duration = 1000.millis, ) ``` 1. The `onlyIfMissing` specifies whether to update only signals that are not already present on the client. 2. The `eventId` is an optional identifier for the event. 3. The `retryDuration` specifies the duration the client should wait before retrying the connection in case of failure. Here is an example of generating the current server time and sending it to the client every second by patching a signal named `currentTime`: ```scala ZIO.clock .flatMap(_.currentDateTime) .map(_.toLocalTime.format(DateTimeFormatter.ofPattern("HH:mm:ss"))) .flatMap { currentTime => ZIO.logInfo(s"Sending time: $currentTime") *> ServerSentEventGenerator.patchSignals( s"{ 'currentTime': '$currentTime' }", PatchSignalOptions(retryDuration = 5.seconds), ) } .schedule(Schedule.spaced(1.second)) .unit ``` This can be used inside a handler that streams the server time to the client. We will end up sending the following SSE events to the client: ```http event: datastar-patch-signals data: signals { 'currentTime': '19:38:43' } retry: 5000 event: datastar-patch-signals data: signals { 'currentTime': '19:38:44' } retry: 5000 event: datastar-patch-signals data: signals { 'currentTime': '19:38:45' } retry: 5000 ... ``` More details about patching signals can be found in the [Datastar documentation](https://data-star.dev/reference/sse_events#datastar-patch-signals). ## Executing Scripts The `ServerSentEventGenerator#executeScript` is used to run JavaScript code on the client. It takes the script as a string and as a second argument, it takes options of type `ExecuteScriptOptions` to specify how the script should be executed: ```scala final case class ExecuteScriptOptions( autoRemove: Boolean = true, attributes: Seq[(String, String)] = Seq.empty, eventId: Option[String] = None, retryDuration: Duration = 1000.millis, ) ``` 1. The `autoRemove` specifies whether to automatically remove the script element after execution. It defaults to `true`. 2. The `attributes` is a sequence of key-value pairs to add as attributes to the script element. 3. The `eventId` is an optional identifier for the event. 4. The `retryDuration` specifies the duration the client should wait before retrying the connection in case of failure. Here is an example of generating console log scripts from the server and sending them to the client: ```scala val message = "Hello, world!" ZIO.foreachDiscard(message.indices) { i => for { _ <- ServerSentEventGenerator.executeScript(js"console.log('Sending substring(0, ${i + 1})')") _ <- ZIO.sleep(100.millis) } yield () } ``` We will end up sending the following SSE events to the client: ```http event: datastar-patch-elements data: selector body data: mode append data: elements event: datastar-patch-elements data: selector body data: mode append data: elements ... event: datastar-patch-elements data: selector body data: mode append data: elements ``` With this, the client will execute each script and log the messages to the console. Datastar finds the `` element, appends the ` // External JavaScript script.externalJs("https://cdn.example.com/lib.js") // Renders as: // // ES6 Module script.externalModule("/js/app.js") // Renders as: // // Inline module script.inlineJs( """ |import { helper } from './utils.js'; |helper.init(); |""".stripMargin, ) // Render as: // // With attributes script .externalJs("/app.js") .async .integrity("sha384-...") .crossOrigin("anonymous") // Renders as: // ``` ### Style Elements Style elements are specialized for CSS content. To include inline CSS, use the `inlineCss` method: ```scala // Inline CSS style.inlineCss( css""" |body { | margin: 0; | font-family: sans-serif; |} |""".stripMargin, ) ``` To inline a CSS file from the resource directory, use `inlineResource`: ```scala // Loading from resources style.inlineResource("styles/main.css") ``` To point to an external CSS file, you can use the `link` element: ```scala link(rel := "stylesheet", href := "https://example.com/styles/main.css") ``` ### Finding, Filtering and Collecting Using `find`, `filter`, and `collect` methods, we can traverse the `Dom` to locate, select, or extract specific elements based on defined criteria: ```scala val element = div( p(`class` := "important")("Important"), p("Normal"), span("Other") ) // Find specific elements val firstP: Option[Dom] = element.find { case el: Dom.Element => el.tag == "p" case _ => false } // Filter elements val filtered = element.filter { case el: Dom.Element => el.attributes.contains("class") case _ => true } // Collect all matching elements val allParagraphs: List[Dom] = element.collect { case el: Dom.Element if el.tag == "p" => el } ``` ## Building Reusable Components We can also build reusable components using functions. Here's an example of a card component: ```scala def card( title: Option[String] = None, footer: Option[Dom] = None, )(content: Dom*): Dom.Element = { div(`class` := "card")( title.map(t => div(`class` := "card-header")(h5(t))), div(`class` := "card-body")(content), footer.map(f => div(`class` := "card-footer")(f)), ) } def customButton( text: String, variant: String = "primary", size: String = "md", ): Dom.Element = button( `class` := (s"btn-$variant", s"btn-$size"), role := "button" )(text) // Usage card( title = Some("User Profile"), footer = Some(customButton("Save")), )( p("Name: John Doe"), p("Email: john@example.com"), ) ``` ## Using Third-Party Template Libraries If you're running your project with other template libraries like Twirl or Scalate and want to migrate to ZIO HTTP, you can integrate them without rewriting all your templates. After migrating and integrating with ZIO HTTP, you can gradually replace your old templates with ZIO HTTP Template2 templates. Here's an example of how to integrate [Twirl](https://github.com/playframework/twirl) templates with ZIO HTTP. Assume you have written a Twirl template named `greetUser.scala.html` inside the `mytwirltemplate` package: ```html @import models.User @(param: User) Greet User Twirl Template Hello, @param.name! Your email is: @param.email ``` And you have a case class `User` defined as follows: ```scala package models case class User(name: String, email: String) object User { implicit val schema = DeriveSchema.gen[User] } ``` Now you can use this Twirl template inside a ZIO HTTP route as follows: ```scala val greetRoute = Method.GET / "greet" -> handler { (req: Request) => for { user <- req.body.to[User] response = mytwirltemplate.greetUser.render(user) } yield Response( body = Body.fromString(response), headers = Headers(Header.ContentType(MediaType.text.html)), ) } ``` If you want to use `Endpoint` API, you can use `RawHtml` to convert your rendered Twirl template into a `Dom`: ```scala val endpoint: Endpoint[Unit, User, ZNothing, Dom, None] = Endpoint(Method.GET / Root) .in[User] .out[Dom](MediaType.text.`html`) val route = endpoint.implementHandler{ handler{ user: User => RawHtml(mytwirltemplate.greetUser.render(user)) } } ``` You can follow a similar approach to integrate other template libraries with ZIO HTTP. --- ## Running the Examples All code examples from this testkit guide are available as runnable tests in the `zio-http-example-testing` project. Each example demonstrates a specific testing pattern and can be compiled and run independently. ## Setup Clone the repository and navigate to the project: ```bash git clone https://github.com/zio/zio-http.git cd zio-http ``` Next, navigate to the examples project: ```bash cd zio-http-example-testing ``` Then, compile all examples: ```bash sbt test:compile ``` ## Example Test Suites The testkit provides example suites demonstrating each testing pattern. ### Direct Route Testing To run the direct route testing examples: ```bash sbt "testOnly example.testing.DirectRouteTestingExamples" ``` **Key patterns covered:** - Simple handler responses (OK status, text responses) - Routing and path matching with specific vs. fallback routes - Extracting path parameters (integers and strings) - Different HTTP methods (GET, POST, etc.) - Reading and echoing request body ### TestServer Integration Testing To run the TestServer integration testing examples: ```bash sbt "testOnly example.testing.TestServerExamples" ``` **Key patterns covered:** - Basic server setup and route configuration - Adding single and multiple routes - Route precedence (specific routes before general fallback routes) - Extracting path parameters in routes - Different HTTP methods on the same path - Server returning correct status codes - Handling unmatched routes (404 responses) - Request body handling and error responses - Integration testing with request-response workflows - State persistence across multiple requests ### TestClient Mocking To run the TestClient mocking examples: ```bash sbt "testOnly example.testing.TestClientExamples" ``` **Key patterns covered:** - Mock exact request-response pairs - Mock multiple different endpoints - Mock different HTTP methods - Flexible route handlers that respond to parameters - Handlers that compute responses from request content - Fallback handlers for capturing unexpected requests - Verifying no extra calls are made - Route accumulation (routes persist across multiple additions) - Mocking error responses from external services - Mocking authentication failures ### WebSocket Testing To run the WebSocket testing examples: ```bash sbt "testOnly example.testing.WebSocketExamples" ``` **Key patterns covered:** - Echo servers that echo messages back to clients - Bidirectional message exchange (client sends, server responds) - Servers that send unsolicited messages (greetings) - Handling different frame types (text and binary) - Stateful WebSocket handlers (counters, session data) - Server-initiated shutdown and connection cleanup - Broadcast pattern demonstration ### Error Handling Patterns To run the error handling examples: ```bash sbt "testOnly example.testing.ErrorHandlingExamples" ``` **Key patterns covered:** - Returns 404 for missing resources - Returns error status codes (400, 500, etc.) - Adds custom error headers - Validates request data - Returns appropriate error responses with messages ### Stateful Handlers To run the stateful handler examples: ```bash sbt "testOnly example.testing.StatefulHandlerExamples" ``` **Key patterns covered:** - Counter handlers that increment on each request - State persistence across multiple requests - Request tracking and counting - Using Ref for mutable state in handlers ## Running All Tests To run all testkit examples at once: ```bash sbt "testOnly example.testing.*" ``` Or compile the entire test suite: ```bash sbt test:compile ``` ## Project Structure The examples follow ZIO HTTP testing conventions: ``` zio-http-example-testing/ ├── src/ │ ├── test/scala/example/testing/ │ │ ├── DirectRouteTestingExamples.scala │ │ ├── TestServerExamples.scala │ │ ├── TestClientExamples.scala │ │ ├── WebSocketExamples.scala │ │ ├── ErrorHandlingExamples.scala │ │ └── StatefulHandlerExamples.scala │ └── main/scala/example/ │ └── (application code if needed) └── build.sbt ``` Each example file is a standalone test suite that can be run independently using `sbt "testOnly"` commands. ## Integration with Documentation These examples directly correspond to sections in the testkit reference: | Example File | Reference | Topics | |---|---|---| | `DirectRouteTestingExamples.scala` | [Direct Route Testing](../../guides/testing-http-apps) | Unit testing, handler logic, routing | | `TestServerExamples.scala` | [TestServer](./test-server.md) | Integration testing, multiple routes, state | | `TestClientExamples.scala` | [TestClient](./test-client.md) | Mocking, external services, fallback handling | | `WebSocketExamples.scala` | [TestChannel](./test-channel.md) | WebSocket, bidirectional messaging, frames | | `ErrorHandlingExamples.scala` | [Error Handling](../../guides/testing-http-apps) | Status codes, error responses | | `StatefulHandlerExamples.scala` | [State Persistence](../../guides/testing-http-apps) | Ref, state management, request tracking | ## Writing Your Own Tests To create your own tests following these patterns: 1. **Extend ZIOSpecDefault** for test suite structure 2. **Use TestServer.default, TestClient.layer, or other providers** as needed 3. **Follow naming conventions** with descriptive suite and test names 4. **Compose handlers** to test multiple routes together 5. **Use Ref for state** to persist state across requests 6. **Handle errors gracefully** with `catchAll` patterns Refer to the individual example files and the [TestServer](./test-server.md), [TestClient](./test-client.md), and [TestChannel](./test-channel.md) reference pages for complete patterns and best practices. --- ## HttpTestAspect `HttpTestAspect` is a ZIO Test aspect utility that temporarily configures HTTP application modes for testing. It provides composable test aspects to run specific tests under different deployment modes (Dev, Preprod, Prod), allowing verification of mode-dependent handler behavior without affecting other tests. Mode settings automatically restore after each test. The public API provides three mode aspects: ```scala object HttpTestAspect { val devMode: TestAspectAtLeastR[Scope] val preprodMode: TestAspectAtLeastR[Scope] val prodMode: TestAspectAtLeastR[Scope] } ``` Key properties: - **Test Composition** — Acts as a ZIO Test aspect applicable with `@@` operator - **Automatic Restoration** — Previous mode automatically restores after test completes - **Scoped Mode Change** — Uses system property to set `Mode.current` for test duration - **No Test Mutation** — Does not affect other tests in the same suite ### Role in Module `HttpTestAspect` is the **test utility for mode-dependent behavior verification** in zio-http-testkit. **Complements:** TestServer (mode-dependent routes), TestClient (mode-dependent external calls), Mode (checks current mode in handlers) ## Motivation HTTP applications often have mode-dependent behavior: different error handling in development vs. production, extra diagnostics in staging, stricter validation in production. Testing this behavior requires temporarily switching the application mode for specific tests. `HttpTestAspect` solves this by providing composable aspects that set the mode for the duration of a test and restore the previous mode afterward: ```scala test("handles errors in dev mode") { // Test logic here assertTrue(Mode.isDev) } @@ HttpTestAspect.devMode ``` Use `HttpTestAspect` when: - You want to test mode-specific error handling or resource allocation - You need to verify feature flags that depend on the mode - Your test suite tests multiple modes and needs isolation ## Mode Types Three modes are available: - **`Mode.Dev`** — Development mode (default). Typically enables extra diagnostics, verbose error messages, and developer-friendly defaults. - **`Mode.Preprod`** — Pre-production/staging mode. An intermediate mode between dev and production for testing production-like behavior in a safe environment. - **`Mode.Prod`** — Production mode. Typically strict validation, minimal error details, optimized performance. ## Using HttpTestAspect To test mode-dependent behavior, apply mode aspects to your tests. ### Basic Mode Override Apply a mode aspect to a test to set the mode for that test only: ```scala title="zio-http-example-testing/src/test/scala/example/testing/ModeBasicSetup.scala" package example.testing object ModeBasicSetup extends ZIOSpecDefault { def spec = suite("Mode Testing")( test("development mode enables verbose logging") { for { currentMode <- ZIO.succeed(Mode.current) } yield assertTrue(currentMode == Mode.Dev) } @@ HttpTestAspect.devMode, test("production mode disables verbose logging") { for { currentMode <- ZIO.succeed(Mode.current) } yield assertTrue(currentMode == Mode.Prod) } @@ HttpTestAspect.prodMode, test("modes are isolated between tests") { for { modeInProd <- ZIO.succeed(Mode.current) } yield assertTrue(modeInProd == Mode.Prod) } @@ HttpTestAspect.prodMode, ) @@ TestAspect.sequential } ``` ([source](https://github.com/zio/zio-http/blob/main/zio-http-example-testing/src/test/scala/example/testing/ModeBasicSetup.scala)) ### Reading the Mode Inside a test, read the current mode using: ```scala // Full mode value val mode: Mode = Mode.current // Convenience checks val isDev = Mode.isDev val isPreprod = Mode.isPreprod val isProd = Mode.isProd ``` ## Core Operations The following examples demonstrate testing different modes. ### Testing Dev Mode Behavior Enable extra diagnostics and verbose error handling: ```scala title="zio-http-example-testing/src/test/scala/example/testing/TestAspectDevMode.scala" package example.testing object TestAspectDevMode extends ZIOSpecDefault { def spec = (test("error response includes stack trace in dev mode") { for { client <- ZIO.service[Client] port <- ZIO.serviceWithZIO[Server](_.port) _ <- TestServer.addRoute { Method.GET / "error" -> handler { (_: Request) => (ZIO.fail(new Exception("Something went wrong")): ZIO[Any, Throwable, Response]) .catchAll { err => val message = if (Mode.isDev) err.toString else "Internal Server Error" ZIO.succeed(Response.status(Status.InternalServerError).addHeader("X-Error", message)) } } } response <- client(Request.get(URL.root.port(port) / "error")) } yield assertTrue( response.status == Status.InternalServerError, Mode.isDev ) }.provide(TestServer.default, Client.default, Scope.default)) @@ HttpTestAspect.devMode } ``` ([source](https://github.com/zio/zio-http/blob/main/zio-http-example-testing/src/test/scala/example/testing/TestAspectDevMode.scala)) ### Testing Prod Mode Behavior Enforce stricter validation and optimized error handling: ```scala title="zio-http-example-testing/src/test/scala/example/testing/TestAspectProdMode.scala" package example.testing object TestAspectProdMode extends ZIOSpecDefault { def spec = (test("request validation is stricter in prod mode") { for { client <- ZIO.service[Client] port <- ZIO.serviceWithZIO[Server](_.port) _ <- TestServer.addRoute { Method.POST / "data" -> handler { (req: Request) => val lenientValidation = Mode.isDev req.body.asString .map { body => if (body.isEmpty && !lenientValidation) Response.status(Status.BadRequest) else Response.ok } .catchAll { _ => ZIO.succeed(Response.status(Status.BadRequest)) } } } // Send empty body response <- client(Request.post(URL.root.port(port) / "data", Body.empty)) } yield assertTrue( response.status == Status.BadRequest, Mode.isProd ) }.provide(TestServer.default, Client.default, Scope.default)) @@ HttpTestAspect.prodMode } ``` ([source](https://github.com/zio/zio-http/blob/main/zio-http-example-testing/src/test/scala/example/testing/TestAspectProdMode.scala)) ### Testing Preprod Mode Behavior Test production-like behavior safely in staging: ```scala title="zio-http-example-testing/src/test/scala/example/testing/TestAspectPreprodMode.scala" package example.testing object TestAspectPreprodMode extends ZIOSpecDefault { def spec = (test("preprod mode enables test endpoints") { for { client <- ZIO.service[Client] port <- ZIO.serviceWithZIO[Server](_.port) // Routes only available in preprod and dev testRoutes = if (Mode.isProd) Routes.empty else Routes( Method.GET / "test" / "reset" -> handler(Response.ok) ) _ <- TestServer.addRoutes(testRoutes) response <- client(Request.get(URL.root.port(port) / "test" / "reset")) } yield assertTrue( response.status == Status.Ok, Mode.isPreprod ) }.provide(TestServer.default, Client.default, Scope.default)) @@ HttpTestAspect.preprodMode } ``` ([source](https://github.com/zio/zio-http/blob/main/zio-http-example-testing/src/test/scala/example/testing/TestAspectPreprodMode.scala)) ### Multiple Mode Tests with Sequential Execution When testing multiple modes in the same suite, use `TestAspect.sequential` to avoid race conditions (mode is a JVM-wide setting): ```scala title="zio-http-example-testing/src/test/scala/example/testing/TestAspectMultiMode.scala" package example.testing object TestAspectMultiMode extends ZIOSpecDefault { def spec = suite("Multi-Mode Testing")( test("dev mode allows verbose output") { assertTrue(Mode.isDev) } @@ HttpTestAspect.devMode, test("preprod mode is intermediate") { assertTrue(Mode.isPreprod) } @@ HttpTestAspect.preprodMode, test("prod mode is strict") { assertTrue(Mode.isProd) } @@ HttpTestAspect.prodMode, ) @@ TestAspect.sequential } ``` ([source](https://github.com/zio/zio-http/blob/main/zio-http-example-testing/src/test/scala/example/testing/TestAspectMultiMode.scala)) ## Common Patterns ### Mode-Conditional Routes Define routes that only exist in certain modes: ```scala val debugRoutes = if (Mode.isDev || Mode.isPreprod) Routes( Method.GET / "debug" / "health" -> handler(Response.text("OK")) ) else Routes.empty val appRoutes = Routes( Method.GET / "health" -> handler(Response.ok) ) ++ debugRoutes ``` ### Mode-Conditional Error Handling Customize error responses based on mode: ```scala handler { (_: Request) => ZIO.fail(new Exception("Something went wrong")) .catchAll { err => if (Mode.isDev || Mode.isPreprod) ZIO.succeed( Response.status(Status.InternalServerError) .addHeader("X-Error", err.getMessage) .addHeader("X-Trace", err.getStackTrace.mkString("\n")) ) else ZIO.succeed(Response.status(Status.InternalServerError)) } } ``` ### Mode-Conditional Configuration Adapt server configuration and resources based on mode: ```scala // Use mode to determine resource allocation val resourcePoolSize = if (Mode.isProd) 16 else 4 val enableVerboseLogging = Mode.isDev // Configure handlers based on mode val handlerConfig = if (Mode.isProd) { // Strict timeout in production ZIO.succeed(5000) } else { // Lenient timeout in development ZIO.succeed(30000) } ``` ### Testing Feature Flags Test handlers that use mode-based feature flags: ```scala title="zio-http-example-testing/src/test/scala/example/testing/TestAspectFeatureFlag.scala" package example.testing object TestAspectFeatureFlag extends ZIOSpecDefault { def spec = (test("new feature is disabled in prod") { for { client <- ZIO.service[Client] port <- ZIO.serviceWithZIO[Server](_.port) _ <- TestServer.addRoute { Method.GET / "new-feature" -> handler { (_: Request) => if (Mode.isProd) ZIO.succeed(Response.status(Status.NotFound)) else ZIO.succeed(Response.text("Feature enabled")) } } response <- client(Request.get(URL.root.port(port) / "new-feature")) } yield assertTrue( response.status == Status.NotFound, Mode.isProd ) }.provide(TestServer.default, Client.default, Scope.default)) @@ HttpTestAspect.prodMode } ``` ([source](https://github.com/zio/zio-http/blob/main/zio-http-example-testing/src/test/scala/example/testing/TestAspectFeatureFlag.scala)) ## Integration with Other Types **`TestServer`** — Use mode aspects with `TestServer` integration tests to verify mode-dependent route behavior: ```scala title="zio-http-example-testing/src/test/scala/example/testing/TestAspectTestServerMode.scala" package example.testing object TestAspectTestServerMode extends ZIOSpecDefault { def spec = (test("server returns different response in prod mode") { for { client <- ZIO.service[Client] port <- ZIO.serviceWithZIO[Server](_.port) _ <- TestServer.addRoute { Method.GET / "api" -> handler { (_: Request) => if (Mode.isProd) ZIO.succeed(Response.text("Production response")) else ZIO.succeed(Response.text("Development response")) } } response <- client(Request.get(URL.root.port(port) / "api")) body <- response.body.asString } yield assertTrue(body == "Production response") }.provide(TestServer.default, Client.default, Scope.default)) @@ HttpTestAspect.prodMode } ``` ([source](https://github.com/zio/zio-http/blob/main/zio-http-example-testing/src/test/scala/example/testing/TestAspectTestServerMode.scala)) **`TestClient`** — Use mode aspects when testing handlers that call external services conditionally based on mode: ```scala title="zio-http-example-testing/src/test/scala/example/testing/TestAspectTestClientMode.scala" package example.testing object TestAspectTestClientMode extends ZIOSpecDefault { def spec = (test("uses mock service in dev mode") { for { client <- ZIO.service[Client] // Configure mock external service _ <- TestClient.addRoute { Method.GET / "external" -> handler(Response.json("""{"mock": true}""")) } // Call it response <- client(Request.get(URL.root / "external")) body <- response.body.asString } yield assertTrue(body.contains("mock")) }.provide(TestClient.layer, Scope.default)) @@ HttpTestAspect.devMode } ``` ([source](https://github.com/zio/zio-http/blob/main/zio-http-example-testing/src/test/scala/example/testing/TestAspectTestClientMode.scala)) ## Best Practices 1. **Use `TestAspect.sequential`** when multiple tests in the same suite use different mode aspects to prevent race conditions 2. **Document why modes differ** in your handler logic to help future maintainers understand the branching 3. **Test all modes** that your application supports to ensure consistent behavior 4. **Keep mode-dependent code simple** — complex branching can hide bugs; prefer simple guards over intricate logic 5. **Combine with config** — For complex environment-dependent behavior (secrets, database URLs), use a dedicated config service alongside modes ## API Reference The following test aspects and mode query functions comprise the complete public API. ### Test Aspects | Aspect | Type | Purpose | |--------|------|---------| | `HttpTestAspect.devMode` | `TestAspectAtLeastR[Scope]` | Run test under Dev mode | | `HttpTestAspect.preprodMode` | `TestAspectAtLeastR[Scope]` | Run test under Preprod mode | | `HttpTestAspect.prodMode` | `TestAspectAtLeastR[Scope]` | Run test under Prod mode | ### Usage Pattern Apply all aspects using ZIO Test's `@@` operator: ```scala test("my test") { // test logic } @@ HttpTestAspect.devMode ``` ### Mode Queries in Handlers See [Reading the Mode](#reading-the-mode) above for available query functions. ## Implementation Details - **Mechanism** — Uses system property `"zio.http.mode"` to set mode - **Scope** — Mode change is scoped to test duration only - **Restoration** — Previous mode is captured and restored after test - **Thread-Safe** — Mode is JVM-wide; use `TestAspect.sequential` for multiple mode tests - **No Manual Cleanup** — Automatic restoration means no try-finally needed ## See Also - [Dev / Preprod / Prod Modes](../../concepts/dev-mode.md) — Comprehensive guide to application modes - [TestServer](./test-server.md) — Integration testing with mode configuration - [TestClient](./test-client.md) — Mocking external services for mode testing - [Testing HTTP Applications](../../guides/testing-http-apps) — Comprehensive testing guide --- ## TestChannel `TestChannel` is an in-memory bidirectional message channel for testing WebSocket handlers. It simulates a WebSocket connection between client and server, allowing handlers to exchange messages without real network I/O. All communication happens in-memory and synchronously, enabling fast, deterministic WebSocket tests. The `TestChannel` type provides: ```scala case class TestChannel( in: Queue[WebSocketChannelEvent], out: Queue[WebSocketChannelEvent], promise: Promise[Nothing, Unit], ) extends WebSocketChannel { def receive: Task[WebSocketChannelEvent] def receiveAll[Env, Err](f: WebSocketChannelEvent => ZIO[Env, Err, Any]): ZIO[Env, Err, Unit] def send(event: WebSocketChannelEvent): Task[Unit] def sendAll(events: Iterable[WebSocketChannelEvent]): Task[Unit] def shutdown: UIO[Unit] def awaitShutdown: UIO[Unit] } ``` Key properties: - **Bidirectional** — Both client and server can send and receive messages independently - **Queue-Based Coordination** — Uses queues (bounded or unbounded, depending on construction) to coordinate message delivery between paired handlers - **Frame Types** — Supports all WebSocket frame types: text, binary, control frames - **Lifecycle Management** — Handles connection handshakes and graceful shutdown - **Automatic Coordination** — Two TestChannels coordinate via shared queues and promises ### Role in Module `TestChannel` is the **primary type for WebSocket testing** in zio-http-testkit. It provides a simulated WebSocket connection that handlers can interact with directly. **Use with:** WebSocketApp (server endpoint), WebSocketHandler (application logic), TestServer or TestClient (to serve/access the WebSocket) **Complementary types:** - TestServer — For testing WebSocket endpoints you serve - TestClient — For testing WebSocket clients you implement - HttpTestAspect — For testing mode-dependent WebSocket behavior ## Motivation WebSocket testing requires special handling compared to HTTP testing: 1. **Bidirectional communication** — Both client and server initiate messages (not request-response) 2. **Long-lived connections** — Connections persist across multiple message exchanges 3. **Stateful handlers** — Handlers maintain state within a connection session 4. **Complex message patterns** — Echo, broadcast, publish-subscribe, request-reply patterns Real WebSocket testing with actual network connections is: - **Slow** — Network I/O adds latency and test duration - **Unreliable** — Network conditions, timeouts make tests flaky - **Hard to test edge cases** — Difficult to simulate specific message sequences or errors - **Difficult to coordinate** — Hard to verify exact message order and content `TestChannel` solves this by providing an in-memory, synchronous channel that executes instantly without network latency, is fully deterministic and controllable, and lets you verify exact message sequences. Use `TestChannel` when testing: - WebSocket echo handlers - Publish-subscribe message brokers - Real-time notification systems - Bidirectional request-reply patterns - Connection lifecycle (handshake, close, error handling) ## Quick Showcase Here's a complete example: create a WebSocket echo handler, test it with TestChannel: ```scala val test = for { // Create input and output queues inQueue <- Queue.unbounded[WebSocketChannelEvent] outQueue <- Queue.unbounded[WebSocketChannelEvent] promise <- Promise.make[Nothing, Unit] // Create two channels: one for client, one for server clientChannel <- TestChannel.make(inQueue, outQueue, promise) serverChannel <- TestChannel.make(outQueue, inQueue, promise) // Echo handler: receives message, sends it back echoHandler = Handler.webSocket { channel: WebSocketChannel => channel.receiveAll { case Read(WebSocketFrame.Text(msg)) => channel.send(Read(WebSocketFrame.text(msg))) case _ => ZIO.unit } } // Server side: run echo handler on server channel (simplified for documentation) // In real tests, this would handle full request/response // Client side: send and receive messages // _ <- clientChannel.send(Read(WebSocketFrame.text("Hello"))) // response <- clientChannel.receive } yield () ``` ## Construction / Creating TestChannel Create TestChannel instances via the factory method: ### `TestChannel.make` — Create Connected Channel Pair ```scala def make( in: Queue[WebSocketChannelEvent], out: Queue[WebSocketChannelEvent], promise: Promise[Nothing, Unit], ): ZIO[Any, Nothing, TestChannel] ``` Use `TestChannel.make` to create a TestChannel from queues and a promise. This is useful for manual setup or advanced scenarios: ```scala val test = for { // Create queues for bidirectional communication queue1 <- Queue.unbounded[WebSocketChannelEvent] queue2 <- Queue.unbounded[WebSocketChannelEvent] // Create promise for shutdown coordination shutdownPromise <- Promise.make[Nothing, Unit] // Create two TestChannels that are connected // Channel1 sends to queue2, receives from queue1 channel1 <- TestChannel.make(queue1, queue2, shutdownPromise) // Channel2 sends to queue1, receives from queue2 channel2 <- TestChannel.make(queue2, queue1, shutdownPromise) } yield (channel1, channel2) ``` Key behavior: - Two TestChannels share queues but with swapped input/output (in-out are reversed) - `promise` coordinates shutdown between both channels - Sending to `out` queue makes data available for the other channel's `WebSocketChannel#receive` ### Manual Channel Creation Pattern For most testing, you don't create TestChannel directly. Instead: - **TestServer** — WebSocket endpoints automatically create TestChannels for incoming connections - **TestClient** — WebSocket clients automatically create TestChannels when connecting - **Handler.webSocket** — Your handler receives a TestChannel automatically ## Core Operations ### Message Exchange Group TestChannel supports bidirectional message transmission using these operations: #### `WebSocketChannel#send` — Send Single Message to Channel ```scala trait WebSocketChannel { def send(event: WebSocketChannelEvent)(implicit trace: Trace): Task[Unit] } ``` Use `send` to transmit a single WebSocket event (message or control frame) to the channel. Both client and server sides can send messages: ```scala val test = for { inQueue <- Queue.unbounded[WebSocketChannelEvent] outQueue <- Queue.unbounded[WebSocketChannelEvent] promise <- Promise.make[Nothing, Unit] channel <- TestChannel.make(inQueue, outQueue, promise) // Send a text message _ <- channel.send(Read(WebSocketFrame.text("Hello"))) // Send a binary message _ <- channel.send(Read(WebSocketFrame.binary(Chunk.fromArray("binary".getBytes)))) } yield () ``` Key behavior: - Non-blocking; offers message to output queue - Fails if queue is full (bounded queue overflow) - Performance: O(1) per send #### `WebSocketChannel#sendAll` — Send Multiple Messages ```scala trait WebSocketChannel { def sendAll(events: Iterable[WebSocketChannelEvent])(implicit trace: Trace): Task[Unit] } ``` Use `sendAll` to transmit multiple WebSocket events atomically. This is useful for bulk message injection: ```scala val test = for { inQueue <- Queue.unbounded[WebSocketChannelEvent] outQueue <- Queue.unbounded[WebSocketChannelEvent] promise <- Promise.make[Nothing, Unit] channel <- TestChannel.make(inQueue, outQueue, promise) // Send multiple messages messages = List( Read(WebSocketFrame.text("Message 1")), Read(WebSocketFrame.text("Message 2")), Read(WebSocketFrame.text("Message 3")) ) _ <- channel.sendAll(messages) } yield () ``` Key behavior: - All messages sent in order - Atomic operation; either all succeed or all fail #### `WebSocketChannel#receive` — Receive Single Message ```scala trait WebSocketChannel { def receive(implicit trace: Trace): Task[WebSocketChannelEvent] } ``` Use `receive` to get one WebSocket event from the channel. This blocks until an event becomes available: ```scala val test = for { inQueue <- Queue.unbounded[WebSocketChannelEvent] outQueue <- Queue.unbounded[WebSocketChannelEvent] promise <- Promise.make[Nothing, Unit] channel1 <- TestChannel.make(inQueue, outQueue, promise) channel2 <- TestChannel.make(outQueue, inQueue, promise) // One channel sends _ <- channel1.send(Read(WebSocketFrame.text("Hello"))) // Other channel receives event <- channel2.receive } yield event // event is Read(WebSocketFrame.Text("Hello")) ``` Key behavior: - Blocks waiting for input queue - Returns any event type: frames, control messages, errors - Performance: O(1) per receive #### `WebSocketChannel#receiveAll` — Receive and Process All Messages ```scala trait WebSocketChannel { def receiveAll[Env, Err]( f: WebSocketChannelEvent => ZIO[Env, Err, Any] )(implicit trace: Trace): ZIO[Env, Err, Unit] } ``` Use `receiveAll` to loop through messages and apply a function to each one. The loop continues until the channel shuts down (receives `Unregistered` event). This is the core pattern for WebSocket handlers: ```scala val test = for { inQueue <- Queue.unbounded[WebSocketChannelEvent] outQueue <- Queue.unbounded[WebSocketChannelEvent] promise <- Promise.make[Nothing, Unit] channel1 <- TestChannel.make(inQueue, outQueue, promise) channel2 <- TestChannel.make(outQueue, inQueue, promise) // Echo handler: receive and send back echoFiber <- channel1.receiveAll { case Read(WebSocketFrame.Text(msg)) => channel1.send(Read(WebSocketFrame.text(msg))) case _ => ZIO.unit }.fork // Send messages from other side _ <- channel2.send(Read(WebSocketFrame.text("Ping"))) // Receive echo echo <- channel2.receive } yield echo // echo is Read(WebSocketFrame.Text("Ping")) ``` Key behavior: - Loop continues until `ChannelEvent.Unregistered` is received - Processes each event with the handler function - Handles both data frames and control frames - Uses `ZIO.yieldNow` for fair scheduling ### Lifecycle Management Manage channel lifecycle using shutdown operations. #### `WebSocketChannel#shutdown` — Gracefully Close Channel ```scala trait WebSocketChannel { def shutdown(implicit trace: Trace): UIO[Unit] } ``` Call `shutdown` to send shutdown signals to both input and output, clean up resources, and exit `WebSocketChannel#receiveAll` loops: ```scala val test = for { inQueue <- Queue.unbounded[WebSocketChannelEvent] outQueue <- Queue.unbounded[WebSocketChannelEvent] promise <- Promise.make[Nothing, Unit] channel <- TestChannel.make(inQueue, outQueue, promise) // Process messages loopFiber <- channel.receiveAll { _ => ZIO.unit }.fork // Later, shutdown the channel _ <- channel.shutdown // receiveAll loop exits due to Unregistered _ <- loopFiber.join } yield () ``` Key behavior: - Sends `Unregistered` to both queues - Completes the shutdown promise - Allows all operations (send/receive) to finish - Always succeeds #### `WebSocketChannel#awaitShutdown` — Wait for Shutdown ```scala trait WebSocketChannel { def awaitShutdown(implicit trace: Trace): UIO[Unit] } ``` Call `awaitShutdown` to block until the channel receives a shutdown signal from the other side: ```scala val test = for { inQueue <- Queue.unbounded[WebSocketChannelEvent] outQueue <- Queue.unbounded[WebSocketChannelEvent] promise <- Promise.make[Nothing, Unit] channel1 <- TestChannel.make(inQueue, outQueue, promise) channel2 <- TestChannel.make(outQueue, inQueue, promise) // One side waits for shutdown waitFiber <- channel1.awaitShutdown.fork // Other side initiates shutdown _ <- ZIO.sleep(10.millis) _ <- channel2.shutdown // Wait completes _ <- waitFiber.join } yield () ``` Key behavior: - Non-blocking on shutdown signal - Waits on shared promise - Allows coordinated shutdown between both sides ## Common Patterns This section shows patterns for testing WebSocket handlers. ### Echo Handler Test a handler that echoes back all text messages: ```scala val test = for { inQueue <- Queue.unbounded[WebSocketChannelEvent] outQueue <- Queue.unbounded[WebSocketChannelEvent] promise <- Promise.make[Nothing, Unit] serverCh <- TestChannel.make(inQueue, outQueue, promise) clientCh <- TestChannel.make(outQueue, inQueue, promise) // Echo handler echoFiber <- serverCh.receiveAll { case Read(WebSocketFrame.Text(msg)) => serverCh.send(Read(WebSocketFrame.text(msg))) case _ => ZIO.unit }.fork // Test: send and receive _ <- clientCh.send(Read(WebSocketFrame.text("Hello"))) response <- clientCh.receive _ <- serverCh.shutdown _ <- clientCh.shutdown _ <- echoFiber.join } yield response ``` ### Stateful Handler Test a handler that maintains state (e.g., counter): ```scala val test = for { inQueue <- Queue.unbounded[WebSocketChannelEvent] outQueue <- Queue.unbounded[WebSocketChannelEvent] promise <- Promise.make[Nothing, Unit] serverCh <- TestChannel.make(inQueue, outQueue, promise) clientCh <- TestChannel.make(outQueue, inQueue, promise) // Handler with mutable state counter <- Ref.make(0) handlerFiber <- serverCh.receiveAll { event => counter.updateAndGet(_ + 1).flatMap { count => serverCh.send(Read(WebSocketFrame.text(s"Count: $count"))) } }.fork // Send messages and check responses _ <- clientCh.send(Read(WebSocketFrame.text("Message 1"))) resp1 <- clientCh.receive _ <- clientCh.send(Read(WebSocketFrame.text("Message 2"))) resp2 <- clientCh.receive _ <- serverCh.shutdown _ <- clientCh.shutdown _ <- handlerFiber.join } yield (resp1, resp2) ``` ### Broadcast Pattern Test a handler that broadcasts messages between two clients: ```scala val test = for { // Create queues for two clients in1 <- Queue.unbounded[WebSocketChannelEvent] out1 <- Queue.unbounded[WebSocketChannelEvent] in2 <- Queue.unbounded[WebSocketChannelEvent] out2 <- Queue.unbounded[WebSocketChannelEvent] promise <- Promise.make[Nothing, Unit] // Server channels for each client server1 <- TestChannel.make(in1, out1, promise) server2 <- TestChannel.make(in2, out2, promise) client1 <- TestChannel.make(out1, in1, promise) client2 <- TestChannel.make(out2, in2, promise) // Broadcast handler: forward messages between servers broadcastFiber <- ZIO.forkAll(List( server1.receiveAll { case Read(WebSocketFrame.Text(msg)) => server2.send(Read(WebSocketFrame.text(s"From 1: $msg"))) }, server2.receiveAll { case Read(WebSocketFrame.Text(msg)) => server1.send(Read(WebSocketFrame.text(s"From 2: $msg"))) } )) // Test: messages flow between clients _ <- client1.send(Read(WebSocketFrame.text("Hello"))) msg2 <- client2.receive } yield msg2 ``` ## Integration with Other Types ### Within Module **`TestServer`** — TestServer automatically uses TestChannel when clients connect to WebSocket endpoints. When you add a WebSocket handler to TestServer and a client connects, TestServer creates TestChannels for bidirectional communication: ```scala // Example WebSocket handler val echoHandler = Handler.webSocket { channel: WebSocketChannel => channel.receiveAll { case Read(WebSocketFrame.Text(msg)) => channel.send(Read(WebSocketFrame.text(msg))) case _ => ZIO.unit } } // This handler would be added to TestServer // TestServer creates TestChannels automatically for each client connection ``` **`TestClient`** — TestClient's WebSocket clients use TestChannel for communication: ```scala val test = for { client <- ZIO.service[Client] // Install WebSocket server in TestClient _ <- TestClient.installSocketApp { Handler.webSocket { channel => channel.receiveAll { case Read(WebSocketFrame.Text(msg)) => channel.send(Read(WebSocketFrame.text(msg))) case _ => ZIO.unit } } } // Your code connects to WebSocket, gets TestChannel automatically } yield () ``` **`HttpTestAspect`** — Apply mode-dependent behavior testing to WebSocket handlers. ### External Modules - **zio-http core** — Uses `WebSocketChannel`, `WebSocketFrame`, `WebSocketChannelEvent` types - **zio** — Uses `ZIO`, `Queue`, `Promise`, `Ref` for effect management and concurrency - **zio-http netty** — Netty driver provides real WebSocket support; TestChannel substitutes in tests ## API Reference ### Public Methods | Method | Signature | Purpose | |--------|-----------|---------| | `WebSocketChannel#send` | `WebSocketChannelEvent => Task[Unit]` | Send single event to channel | | `WebSocketChannel#sendAll` | `Iterable[WebSocketChannelEvent] => Task[Unit]` | Send multiple events | | `WebSocketChannel#receive` | `Task[WebSocketChannelEvent]` | Receive single event | | `WebSocketChannel#receiveAll` | `(WebSocketChannelEvent => ZIO[Env, Err, Any]) => ZIO[Env, Err, Unit]` | Loop receive and process | | `WebSocketChannel#shutdown` | `UIO[Unit]` | Gracefully close channel | | `WebSocketChannel#awaitShutdown` | `UIO[Unit]` | Wait for shutdown signal | ### Companion Object | Method | Signature | Purpose | |--------|-----------|---------| | `TestChannel.make` | `(Queue, Queue, Promise) => ZIO[Any, Nothing, TestChannel]` | Create TestChannel from queues | ### WebSocketChannelEvent Types | Event | Description | |-------|-------------| | `Read(frame)` | Incoming WebSocket frame (text, binary, etc.) | | `Unregistered` | Channel shutdown signal | | `ExceptionCaught(error)` | Error occurred in channel | | `UserEventTriggered(event)` | Custom user events | ## See Also - [TestServer](./test-server.md) — Testing WebSocket endpoints - [TestClient](./test-client.md) — Testing WebSocket clients - [HttpTestAspect](./http-test-aspect.md) — Testing mode-dependent behavior --- ## TestClient `TestClient` is an in-memory HTTP client driver for mocking external API dependencies in tests. Instead of making real HTTP calls to external services, TestClient intercepts requests and returns configured responses. All communication happens in-memory and synchronously, enabling fast, deterministic tests without external dependencies. The `TestClient` type provides: ```scala final case class TestClient( behavior: Ref[Routes[Any, Response]], serverSocketBehavior: Ref[WebSocketApp[Any]], missingRouteHandler: Ref[Handler[Any, Response, Request, Response]], ) extends ZClient.Driver[Any, Scope, Throwable] { def addRoute[R](route: Route[R, Response]): ZIO[R, Nothing, Unit] def addRoutes[R](route: Route[R, Response], routes: Route[R, Response]*): ZIO[R, Nothing, Unit] def addRequestResponse(expectedRequest: Request, response: Response): ZIO[Any, Nothing, Unit] def setFallbackHandler[R](fallbackFunction: Request => ZIO[R, Response, Response]): ZIO[R, Nothing, Unit] def installSocketApp[Env1](app: WebSocketApp[Any]): ZIO[Env1, Nothing, Unit] } ``` Key properties: - **Mocking Client** — Implements `ZClient.Driver` interface, works as a drop-in `Client` replacement - **In-Memory Responses** — Returns configured responses without network I/O - **Dynamic Route Configuration** — Add routes, request/response pairs, or handlers during test execution - **Fallback Handling** — Optional fallback handler for unexpected requests - **WebSocket Support** — Can mock WebSocket server endpoints via `TestClient#installSocketApp` ### Role in Module `TestClient` is the **primary type for mocking external dependencies** in zio-http-testkit. It mocks the HTTP client to simulate external API calls your application makes. **Typically used with:** Routes (what external API should respond with), Handler (how to compute responses), TestServer (together when testing full request/response flows) **Complementary types:** - TestServer — For testing your own routes and handlers - TestChannel — For testing WebSocket communication - HttpTestAspect — For testing mode-dependent behavior ## Motivation Many applications depend on external HTTP APIs: payment processors, auth services, third-party data sources. Testing code that calls these APIs is challenging: 1. **Real API calls are slow** — Each request waits for network I/O, making tests slow (seconds per test) 2. **Real APIs are unreliable** — External services may go down, become rate-limited, or be unavailable during CI 3. **Hard to test edge cases** — How do you test timeout behavior, 5xx errors, or rate-limit responses without actually calling the API? 4. **Mocking is naive** — Hand-mocked responses often diverge from real API behavior, letting bugs slip through `TestClient` solves this by intercepting HTTP calls and returning configured responses in-memory, letting you mock external APIs while still exercising the complete HTTP flow with realistic request/response handling. Use `TestClient` when your code makes HTTP calls to external services and you want to mock responses, verify request correctness, test error handling, or run tests without external service dependencies. ## Quick Showcase Here's a complete example: configure TestClient to mock an external API, then verify your code calls it correctly: ```scala val test = for { // TestClient is automatically provided as Client client <- ZIO.service[Client] // 1. Mock external API response _ <- TestClient.addRequestResponse( Request.get(URL.root / "external-api" / "users" / "123"), Response.text("""{"id": 123, "name": "Alice"}""") ) // 2. Your code calls the external API (intercepted by TestClient) resp <- client(Request.get(URL.root / "external-api" / "users" / "123")) body <- resp.body.asString } yield body // test: ZIO[Client with TestClient with Any & Scope, Throwable, String] = FlatMap( // trace = "repl.MdocSession.MdocApp0.test(test-client.md:19)", // first = Stateful( // trace = "repl.MdocSession.MdocApp0.test(test-client.md:19)", // onState = zio.FiberRef$unsafe$PatchFiber$$Lambda/0x00007f3ae293c000@19b7312d // ), // successK = // ) // Result: """{"id": 123, "name": "Alice"}""" ``` ## Construction / Creating TestClient TestClient instances are created via ZIO layers that provide both `TestClient` and `Client` services: ### `TestClient.layer` — Basic TestClient Layer ```scala val testClientLayer: ZLayer[Any, Nothing, TestClient & Client] = TestClient.layer ``` This creates a TestClient instance with an empty behavior (no routes configured) and a default fallback handler that logs warnings for unexpected requests. To use `TestClient.layer`, set up routes and make requests: ```scala val test = for { client <- ZIO.service[Client] // Add mock responses _ <- TestClient.addRequestResponse( Request.get(URL.root / "api"), Response.ok ) resp <- client(Request.get(URL.root / "api")) } yield resp.status val result = test.provideLayer(TestClient.layer >+> Scope.default) ``` Key behavior: - Provides both `TestClient` and `Client` services (since TestClient implements the Client interface) - Default fallback logs warnings for unexpected requests - Routes configuration starts empty ### `TestClient.withFallbackHandler` — Custom Fallback Behavior ```scala def withFallbackHandler[R]( fallbackHandler: Request => ZIO[R, Response, Response] ): ZLayer[R, Nothing, TestClient & Client] ``` Creates TestClient with a custom fallback handler for unexpected requests. Useful for testing error handling or providing default behavior. ```scala val customFallbackLayer = TestClient.withFallbackHandler { (req: Request) => // Custom fallback: return 404 for unexpected requests ZIO.succeed(Response.notFound) } val test = for { client <- ZIO.service[Client] // Add one mock response _ <- TestClient.addRequestResponse( Request.get(URL.root / "known"), Response.ok ) // Expected request succeeds resp1 <- client(Request.get(URL.root / "known")) // Unexpected request uses fallback (404) resp2 <- client(Request.get(URL.root / "unknown")) } yield (resp1.status, resp2.status) val result = test.provideLayer(customFallbackLayer >+> Scope.default) ``` ## Core Operations ### Route Configuration Group Configure how TestClient responds to HTTP requests using these methods: #### `TestClient#addRoute` — Add Dynamic Route Handler ```scala trait TestClient { def addRoute[R](route: Route[R, Response]): ZIO[R, Nothing, Unit] } ``` Add a route with a handler function that computes responses dynamically based on the request. This is useful for logic-based responses: ```scala val test = for { client <- ZIO.service[Client] // Add a dynamic route that extracts path parameter _ <- TestClient.addRoute { Method.GET / "users" / int("id") -> handler { (id: Int, _: Request) => Response.text(s"""{"id": $id, "name": "User $id"}""") } } resp <- client(Request.get(URL.root / "users" / "42")) body <- resp.body.asString } yield body // test: ZIO[Client with Any with TestClient with Any & Scope, Throwable, String] = FlatMap( // trace = "repl.MdocSession.MdocApp5.test(test-client.md:148)", // first = Stateful( // trace = "repl.MdocSession.MdocApp5.test(test-client.md:148)", // onState = zio.FiberRef$unsafe$PatchFiber$$Lambda/0x00007f3ae293c000@5986a226 // ), // successK = // ) // Result: """{"id": 42, "name": "User 42"}""" ``` Key behavior: - Route handlers execute in the request context and can access request data - Performance: O(1) route matching, same as HTTP routes - Accumulates with existing routes; first match wins #### `TestClient#addRoutes` — Add Multiple Dynamic Routes ```scala trait TestClient { def addRoutes[R]( route: Route[R, Response], routes: Route[R, Response]* ): ZIO[R, Nothing, Unit] } ``` Add multiple routes with handler functions. This is useful for comprehensive API mocking: ```scala val test = for { client <- ZIO.service[Client] // Mock a complete external API _ <- TestClient.addRoutes( Method.GET / "users" -> handler { Response.text("[user1, user2]") }, Method.GET / "users" / int("id") -> handler { (id: Int, _: Request) => Response.text(s"User $id") }, Method.POST / "users" -> handler { Response.status(Status.Created) } ) listResp <- client(Request.get(URL.root / "users")) oneResp <- client(Request.get(URL.root / "users" / "5")) createResp <- client(Request.post(URL.root / "users", Body.empty)) } yield (listResp.status, oneResp.status, createResp.status) // test: ZIO[Client with Any with TestClient with Any & Scope, Throwable, (Status, Status, Status)] = FlatMap( // trace = "repl.MdocSession.MdocApp7.test(test-client.md:190)", // first = Stateful( // trace = "repl.MdocSession.MdocApp7.test(test-client.md:190)", // onState = zio.FiberRef$unsafe$PatchFiber$$Lambda/0x00007f3ae293c000@2e79eecc // ), // successK = // ) // Result: (Status.Ok, Status.Ok, Status.Created) ``` Key behavior: - All routes added together - Route matching follows HTTP routing rules #### `TestClient#addRequestResponse` — Exact Request/Response Matching ```scala trait TestClient { def addRequestResponse( expectedRequest: Request, response: Response ): ZIO[Any, Nothing, Unit] } ``` Define a 1-1 mapping between an exact request and a fixed response. This is the simplest form of mocking for fixed scenarios. Matches on: - **Method** — Must match exactly - **Path** — Must match exactly - **Headers** — Expected request headers must all be present (actual request can have additional) The request must match exactly, or the TestClient throws `MatchError`: ```scala val test = for { client <- ZIO.service[Client] // Mock exact request/response apiUrl = URL.root / "api" / "data" apiReq = Request.get(apiUrl) apiResp = Response.text("Expected data") _ <- TestClient.addRequestResponse(apiReq, apiResp) // Exact request succeeds resp1 <- client(apiReq) body1 <- resp1.body.asString } yield body1 // test: ZIO[Client with TestClient with Any & Scope, Throwable, String] = FlatMap( // trace = "repl.MdocSession.MdocApp9.test(test-client.md:235)", // first = Mapped( // trace = "repl.MdocSession.MdocApp9.test(test-client.md:235)", // first = Stateful( // trace = "repl.MdocSession.MdocApp9.test(test-client.md:235)", // onState = zio.FiberRef$unsafe$PatchFiber$$Lambda/0x00007f3ae293c000@268eda33 // ), // successK = // ), // successK = // ) // Result: "Expected data" ``` Key behavior: - Throws `MatchError` if request doesn't match (use dynamic routes for flexible matching) - Useful for simple "API always returns X" scenarios - Strict matching ensures tests catch changes in request format #### `TestClient#setFallbackHandler` — Fallback for Unmatched Requests ```scala trait TestClient { def setFallbackHandler[R]( fallbackFunction: Request => ZIO[R, Response, Response] ): ZIO[R, Nothing, Unit] } ``` Set a handler for requests that don't match any configured route. This is useful for default behavior or error handling: ```scala val test = for { client <- ZIO.service[Client] // Track unexpected requests unexpected <- Ref.make[List[String]](List.empty) // Set fallback to track and return error _ <- TestClient.setFallbackHandler { (req: Request) => for { _ <- unexpected.update(_ :+ req.url.toString) } yield Response.status(Status.ServiceUnavailable) } // Add one expected route _ <- TestClient.addRoute { Method.GET / "expected" -> handler { Response.ok } } // Expected request succeeds resp1 <- client(Request.get(URL.root / "expected")) // Unexpected request uses fallback resp2 <- client(Request.get(URL.root / "unexpected")) unexpectedList <- unexpected.get } yield (resp1.status, resp2.status, unexpectedList.length) // test: ZIO[Client with Any with TestClient with Any & Scope, Throwable, (Status, Status, Int)] = FlatMap( // trace = "repl.MdocSession.MdocApp11.test(test-client.md:276)", // first = Stateful( // trace = "repl.MdocSession.MdocApp11.test(test-client.md:276)", // onState = zio.FiberRef$unsafe$PatchFiber$$Lambda/0x00007f3ae293c000@7d926615 // ), // successK = // ) // Result: (Status.Ok, Status.ServiceUnavailable, 1) ``` Key behavior: - Replaces the default fallback handler - Useful for testing error handling or capturing unexpected requests - Executes in the request context; can inspect request details ### WebSocket Support #### `TestClient#installSocketApp` — Mock WebSocket Server ```scala trait TestClient { def installSocketApp[Env1](app: WebSocketApp[Any]): ZIO[Env1, Nothing, Unit] } ``` Use `installSocketApp` to configure TestClient to handle WebSocket upgrade requests. When your code initiates a WebSocket connection, TestClient runs the configured WebSocket app, allowing bidirectional message exchange via TestChannel: ```scala val test = for { client <- ZIO.service[Client] // Mock WebSocket server with echo behavior _ <- TestClient.installSocketApp { Handler.webSocket { channel => channel.receiveAll { msg => channel.send(msg) // Echo back } } } // Your code initiates WebSocket connection // Communication happens via TestChannel } yield () ``` Key behavior: - Handles WebSocket upgrade handshakes automatically - Messages flow bidirectionally through TestChannel - Both client and server handlers run concurrently ## Common Patterns This section shows practical patterns for using TestClient. ### Mocking External REST API Test code that calls an external REST API: ```scala val test = for { client <- ZIO.service[Client] // Mock external payment API _ <- TestClient.addRoute( Method.POST / "api" / "payments" -> handler { Response.text("""{"status": "success", "transactionId": "12345"}""") } ) // Your application code calls the mocked API paymentReq = Request.post(URL.root / "api" / "payments", Body.fromString("""{"amount": 100}""")) paymentResp <- client(paymentReq) paymentBody <- paymentResp.body.asString } yield paymentBody // test: ZIO[Client with Any with TestClient with Any & Scope, Throwable, String] = FlatMap( // trace = "repl.MdocSession.MdocApp14.test(test-client.md:361)", // first = Stateful( // trace = "repl.MdocSession.MdocApp14.test(test-client.md:361)", // onState = zio.FiberRef$unsafe$PatchFiber$$Lambda/0x00007f3ae293c000@5ca2490a // ), // successK = // ) ``` ### Testing Error Handling Test how your code handles API errors: ```scala val test = for { client <- ZIO.service[Client] // Mock API that returns 5xx error _ <- TestClient.addRoute { Method.GET / "api" / "unstable" -> handler { Response.status(Status.InternalServerError) } } // Your code should handle the error gracefully resp <- client(Request.get(URL.root / "api" / "unstable")) status = resp.status } yield status // test: ZIO[Client with Any with TestClient with Any & Scope, Throwable, Status] = FlatMap( // trace = "repl.MdocSession.MdocApp15.test(test-client.md:388)", // first = Stateful( // trace = "repl.MdocSession.MdocApp15.test(test-client.md:388)", // onState = zio.FiberRef$unsafe$PatchFiber$$Lambda/0x00007f3ae293c000@32d5ad3 // ), // successK = // ) // Result: Status.InternalServerError ``` ### Multiple Mock APIs Test code that integrates multiple external services: ```scala val test = for { client <- ZIO.service[Client] // Mock first external API _ <- TestClient.addRoute { Method.GET / "auth-api" / "validate" -> handler { Response.text("""{"valid": true}""") } } // Mock second external API _ <- TestClient.addRoute { Method.GET / "data-api" / "fetch" -> handler { Response.text("""{"data": "content"}""") } } // Your code calls both APIs authResp <- client(Request.get(URL.root / "auth-api" / "validate")) dataResp <- client(Request.get(URL.root / "data-api" / "fetch")) } yield (authResp.status, dataResp.status) // test: ZIO[Client with Any with TestClient with Any & Scope, Throwable, (Status, Status)] = FlatMap( // trace = "repl.MdocSession.MdocApp16.test(test-client.md:416)", // first = Stateful( // trace = "repl.MdocSession.MdocApp16.test(test-client.md:416)", // onState = zio.FiberRef$unsafe$PatchFiber$$Lambda/0x00007f3ae293c000@64179710 // ), // successK = // ) ``` ## Integration with Other Types ### Within Module TestClient and TestServer work together in several ways. **`TestServer`** — TestClient and TestServer work together: - TestServer tests your routes - TestClient mocks external APIs your routes call Example: TestServer handler calls mocked external API: ```scala val test = for { client <- ZIO.service[Client] serverPort <- ZIO.serviceWithZIO[Server](_.port) // Mock external API with TestClient _ <- TestClient.addRoute { Method.GET / "external" -> handler { Response.text("External data") } } // Handler in TestServer calls mocked external API _ <- TestServer.addRoute { Method.GET / "aggregate" -> handler { Response.text("Aggregated: External data") } } // Test the complete flow resp <- client(Request.get(URL.root.port(serverPort) / "aggregate")) } yield resp.status ``` **`TestChannel`** — TestClient handles WebSocket via installSocketApp, using TestChannel underneath. **`HttpTestAspect`** — Apply mode-dependent behavior testing to TestClient routes. ### External Modules - **zio-http core** — TestClient uses `Request`, `Response`, `Client` types from the main HTTP library - **zio** — Uses `ZIO`, `Ref`, `ZLayer` for effect management and test setup - **zio-http netty** — Depends on Netty driver layer for protocol support ## API Reference ### Public Instance Methods | Method | Signature | Purpose | |--------|-----------|---------| | `TestClient#addRoute` | `[R] Route[R, Response] => ZIO[R, Nothing, Unit]` | Add dynamic route handler | | `TestClient#addRoutes` | `[R] Route[R, Response] + Routes[R, Response]* => ZIO[R, Nothing, Unit]` | Add multiple routes | | `TestClient#addRequestResponse` | `Request, Response => ZIO[Any, Nothing, Unit]` | Add exact request/response mapping | | `TestClient#setFallbackHandler` | `[R] (Request => ZIO[R, Response, Response]) => ZIO[R, Nothing, Unit]` | Set fallback for unmatched requests | | `TestClient#installSocketApp` | `[Env] WebSocketApp[Any] => ZIO[Env, Nothing, Unit]` | Configure WebSocket handler | ### Companion Object Methods | Method | Signature | Purpose | |--------|-----------|---------| | `TestClient.layer` | `ZLayer[Any, Nothing, TestClient & Client]` | Create TestClient layer | | `TestClient.withFallbackHandler` | `[R] (Request => ZIO[R, Response, Response]) => ZLayer[R, Nothing, TestClient & Client]` | Create TestClient with custom fallback | | `TestClient#addRoute` | `[R] Route[R, Response] => ZIO[R with TestClient, Nothing, Unit]` | Service method for adding route | | `TestClient#addRoutes` | `[R] Route[R, Response] + Routes[R, Response]* => ZIO[R with TestClient, Nothing, Unit]` | Service method for adding routes | | `TestClient#addRequestResponse` | `Request, Response => ZIO[TestClient, Nothing, Unit]` | Service method for request/response | | `TestClient#setFallbackHandler` | `[R] (Request => ZIO[R, Response, Response]) => ZIO[R with TestClient, Nothing, Unit]` | Service method for fallback | | `TestClient#installSocketApp` | `WebSocketApp[Any] => ZIO[TestClient, Nothing, Unit]` | Service method for WebSocket | ## See Also - [TestServer](./test-server.md) — Testing your own routes and handlers - [TestChannel](./test-channel.md) — Testing WebSocket handlers - [HttpTestAspect](./http-test-aspect.md) — Testing mode-dependent behavior --- ## TestServer `TestServer` is an integration testing HTTP server that simulates a real server listening on localhost. Unlike a real production server, it skips external network latency and disk I/O, keeping tests fast and deterministic. It accepts configured routes and responds to HTTP requests via a standard `Client`. Requests run through the full HTTP stack on localhost, ensuring realistic behavior while remaining deterministic. The `TestServer` type provides: ```scala final case class TestServer(driver: Driver, bindPort: Int) extends Server { def addRoute[R](route: Route[R, Response]): ZIO[R, Nothing, Unit] def addRoutes[R](routes: Routes[R, Response]): ZIO[R, Nothing, Unit] def addRequestResponse(expectedRequest: Request, response: Response): ZIO[Any, Nothing, Unit] def port: UIO[Int] } ``` Key properties: - **Localhost Binding** — Binds to localhost on an automatically assigned port; uses real network I/O but eliminates external network latency and disk I/O - **Mutable Route Configuration** — Add routes dynamically during test execution using `TestServer#addRoute`, `TestServer#addRoutes`, or `TestServer#addRequestResponse` - **Standard Server Interface** — Extends `Server` and works with the standard `Client` interface - **Port Binding** — Binds to an automatically assigned port; query it with `TestServer.port` ## Motivation Testing HTTP applications requires more than unit testing individual handlers. Real servers require startup, actual HTTP calls, verification, and teardown—an approach that is **slow** (seconds per test), **hard to debug** (network I/O adds noise), and **difficult to test edge cases** (timeouts, rate limits, failures). `TestServer` solves this by running your routes in-process on localhost. While requests use the real HTTP stack with loopback network I/O, this eliminates external network latency and disk I/O, while preserving the full request/response cycle and keeping tests fast and deterministic. Use `TestServer` when testing multiple routes together, including route precedence, state persistence across requests, and complete feature workflows. ## Quick Showcase Here's a complete example showing the core capabilities. Set up TestServer with routes, make requests, and verify responses: ```scala val test = for { // 1. Get the client and server port from the environment client <- ZIO.service[Client] port <- ZIO.serviceWithZIO[Server](_.port) // 2. Add routes to TestServer _ <- TestServer.addRoutes { Routes( Method.GET / "hello" -> handler { Response.text("Hello World!") }, Method.GET / "users" / int("id") -> handler { (id: Int, _: Request) => Response.text(s"User $id") } ) } // 3. Make requests via the standard Client interface helloUrl = URL.root.port(port) / "hello" helloResp <- client(Request.get(helloUrl)) helloBody <- helloResp.body.asString userId = 42 userUrl = URL.root.port(port) / "users" / userId.toString userResp <- client(Request.get(userUrl)) userBody <- userResp.body.asString } yield (helloBody, userBody) // test: ZIO[Client with Any with TestServer with Any & Scope, Throwable, (String, String)] = FlatMap( // trace = "repl.MdocSession.MdocApp0.test(test-server.md:19)", // first = Stateful( // trace = "repl.MdocSession.MdocApp0.test(test-server.md:19)", // onState = zio.FiberRef$unsafe$PatchFiber$$Lambda/0x00007f3ae293c000@2ce0de4b // ), // successK = // ) // Run the test // val result = test.provideSomeLayer[Client](TestServer.default) // Output: ("Hello World!", "User 42") ``` This demonstrates the complete workflow: provide `TestServer.default` as a layer, add routes with `TestServer#addRoutes`, retrieve the port with `TestServer#port`, and make requests via the standard `Client` interface. ## Construction / Creating TestServer TestServer instances are created via ZIO layers. There are two main approaches: ### `TestServer.default` — Preconfigured Server (Recommended) The simplest way to get started. Provides a fully configured TestServer with sensible defaults: ```scala val testServerLayer: ZLayer[Any, Nothing, TestServer] = TestServer.default ``` Configuration includes: - Automatic port binding to any available open port - Netty HTTP driver with optimized settings - Fast shutdown without forcing pending connections Use this in tests: ```scala val myTest = for { client <- ZIO.service[Client] port <- ZIO.serviceWithZIO[Server](_.port) _ <- TestServer.addRoute { Method.GET / "test" -> handler { Response.ok } } resp <- client(Request.get(URL.root.port(port) / "test")) } yield resp.status val result = myTest.provideSome[Client]( TestServer.default, Scope.default ) ``` ### `TestServer.layer` — Custom Driver Configuration (Advanced) For advanced use cases, combine `TestServer.layer` with a custom `Driver` to customize server behavior: ```scala val layer: ZLayer[Driver & Server.Config, Throwable, TestServer] = TestServer.layer ``` This requires you to provide: - A `Driver` implementation (typically `NettyDriver`) - A `Server.Config` for configuration options Useful when customizing server behavior (port, socket options, timeouts, buffer sizes): ```scala val customLayer = ZLayer.make[TestServer][Nothing]( TestServer.layer.orDie, ZLayer.succeed(Server.Config.default.onAnyOpenPort), NettyDriver.customized.orDie, ZLayer.succeed(NettyConfig.defaultWithFastShutdown), ) ``` ## Core Operations ### Route Configuration Group Add routes dynamically during test execution using these three methods: #### `TestServer#addRoute` — Add a Single Route ```scala trait TestServer { def addRoute[R](route: Route[R, Response]): ZIO[R, Nothing, Unit] } ``` Add a single route pattern to handle matching requests. The route combines with existing routes using route matching rules (first match wins): ```scala val test = for { client <- ZIO.service[Client] port <- ZIO.serviceWithZIO[Server](_.port) // Add a single route that matches GET /hello _ <- TestServer.addRoute { Method.GET / "hello" -> handler { Response.text("Hello!") } } resp <- client(Request.get(URL.root.port(port) / "hello")) body <- resp.body.asString } yield body // test: ZIO[Client with Any with TestServer with Any & Scope, Throwable, String] = FlatMap( // trace = "repl.MdocSession.MdocApp6.test(test-server.md:153)", // first = Stateful( // trace = "repl.MdocSession.MdocApp6.test(test-server.md:153)", // onState = zio.FiberRef$unsafe$PatchFiber$$Lambda/0x00007f3ae293c000@66f7a917 // ), // successK = // ) // Result: "Hello!" ``` Key behavior: - Routes accumulate in the order they are added; earlier routes are checked before later ones - Provides the route's environment `R` into the effect - Performance: route matching is O(n) where n is the number of routes #### `TestServer#addRoutes` — Add Multiple Routes ```scala trait TestServer { def addRoutes[R](routes: Routes[R, Response]): ZIO[R, Nothing, Unit] } ``` Add multiple routes atomically. This is useful for related routes or comprehensive test setup: ```scala val test = for { client <- ZIO.service[Client] port <- ZIO.serviceWithZIO[Server](_.port) // Add multiple routes at once _ <- TestServer.addRoutes { Routes( Method.GET / "users" -> handler { Response.text("Users list") }, Method.GET / "posts" -> handler { Response.text("Posts list") }, Method.POST / "users" -> handler { Response.status(Status.Created) }, ) } resp <- client(Request.get(URL.root.port(port) / "users")) body <- resp.body.asString } yield body // test: ZIO[Client with Any with TestServer with Any & Scope, Throwable, String] = FlatMap( // trace = "repl.MdocSession.MdocApp8.test(test-server.md:191)", // first = Stateful( // trace = "repl.MdocSession.MdocApp8.test(test-server.md:191)", // onState = zio.FiberRef$unsafe$PatchFiber$$Lambda/0x00007f3ae293c000@2617fa15 // ), // successK = // ) // Result: "Users list" ``` Key behavior: - All routes added together maintain their relative ordering - Routes from `TestServer#addRoute` and `TestServer#addRoutes` interleave based on call order #### `TestServer#addRequestResponse` — Exact Request/Response Matching ```scala trait TestServer { def addRequestResponse(expectedRequest: Request, response: Response): ZIO[Any, Nothing, Unit] } ``` Define a 1-1 mapping between an exact request and a fixed response. This is useful for simple "request X always gets response Y" scenarios. Matches on: - **Method** — Must match exactly - **Path** — Must match exactly - **Headers** — Expected request headers must all be present (actual request can have additional headers) Returns `Response.notFound` when the request doesn't match: ```scala val test = for { client <- ZIO.service[Client] port <- ZIO.serviceWithZIO[Server](_.port) // Define exact request/response mapping request = Request.get(URL.root.port(port) / "api" / "data") response = Response.text("Expected data") _ <- TestServer.addRequestResponse(request, response) // This request matches resp1 <- client(request) body1 <- resp1.body.asString // This request doesn't match (different path) resp2 <- client(Request.get(URL.root.port(port) / "api" / "other")) } yield (body1, resp2.status) // test: ZIO[Client with TestServer with Any & Scope, Throwable, (String, Status)] = FlatMap( // trace = "repl.MdocSession.MdocApp10.test(test-server.md:233)", // first = Stateful( // trace = "repl.MdocSession.MdocApp10.test(test-server.md:233)", // onState = zio.FiberRef$unsafe$PatchFiber$$Lambda/0x00007f3ae293c000@61e02b82 // ), // successK = // ) // Result: ("Expected data", Status.NotFound) ``` Key behavior: - Internally implemented as a route with strict request matching - Useful for mocking external API responses in integration tests - Returns 404 if no match ### Server Information #### `TestServer#port` — Get Bound Port ```scala trait Server { def port: UIO[Int] } ``` Use `port` to query the actual port that TestServer bound to. This is useful because TestServer binds to an automatically assigned available port: ```scala val test = for { server <- ZIO.service[Server] port <- server.port url = URL.root.port(port) } yield url // test: ZIO[Server, Nothing, URL] = FlatMap( // trace = "repl.MdocSession.MdocApp12.test(test-server.md:275)", // first = Stateful( // trace = "repl.MdocSession.MdocApp12.test(test-server.md:275)", // onState = zio.FiberRef$unsafe$PatchFiber$$Lambda/0x00007f3ae293c000@5ba210b1 // ), // successK = // ) // URL is bound to the actual assigned port ``` Key behavior: - O(1) lookup; returns the port immediately - Always succeeds (port is assigned during layer initialization) ## Common Patterns This section demonstrates practical patterns for using TestServer. ### Testing Multiple Routes Together Test that several routes coexist and respond correctly: ```scala val test = for { client <- ZIO.service[Client] port <- ZIO.serviceWithZIO[Server](_.port) // Add related routes _ <- TestServer.addRoutes { Routes( Method.GET / "items" -> handler { Response.text("[item1, item2]") }, Method.GET / "items" / "1" -> handler { Response.text("item1") }, Method.POST / "items" -> handler { Response.status(Status.Created) }, ) } // Test each route allResp <- client(Request.get(URL.root.port(port) / "items")) oneResp <- client(Request.get(URL.root.port(port) / "items" / "1")) createResp <- client(Request.post(URL.root.port(port) / "items", Body.empty)) } yield (allResp.status, oneResp.status, createResp.status) // test: ZIO[Client with Any with TestServer with Any & Scope, Throwable, (Status, Status, Status)] = FlatMap( // trace = "repl.MdocSession.MdocApp13.test(test-server.md:294)", // first = Stateful( // trace = "repl.MdocSession.MdocApp13.test(test-server.md:294)", // onState = zio.FiberRef$unsafe$PatchFiber$$Lambda/0x00007f3ae293c000@4d8add14 // ), // successK = // ) ``` ### Testing Route Matching and Precedence Routes are evaluated in order. Test that specific routes are tried before fallbacks: ```scala val test = for { client <- ZIO.service[Client] port <- ZIO.serviceWithZIO[Server](_.port) // Add specific route _ <- TestServer.addRoute { Method.GET / "special" -> handler { Response.text("Special path") } } // Add fallback route _ <- TestServer.addRoute { Method.ANY / trailing -> handler { Response.text("Fallback") } } // Specific route matches specialResp <- client(Request.get(URL.root.port(port) / "special")) // Any other route falls through to fallback otherResp <- client(Request.get(URL.root.port(port) / "other")) } yield (specialResp.status, otherResp.status) // test: ZIO[Client with Any with TestServer with Any & Scope, Throwable, (Status, Status)] = FlatMap( // trace = "repl.MdocSession.MdocApp14.test(test-server.md:326)", // first = Stateful( // trace = "repl.MdocSession.MdocApp14.test(test-server.md:326)", // onState = zio.FiberRef$unsafe$PatchFiber$$Lambda/0x00007f3ae293c000@294a45ae // ), // successK = // ) ``` ### Testing State Across Requests TestServer state (via `Ref`, databases, etc.) persists across requests: ```scala val test = for { client <- ZIO.service[Client] port <- ZIO.serviceWithZIO[Server](_.port) // Handler with mutable state state <- Ref.make(0) _ <- TestServer.addRoute { Method.POST / "increment" -> handler { (req: Request) => for { newValue <- state.updateAndGet(_ + 1) } yield Response.text(newValue.toString) } } // First request increments from 0 -> 1 resp1 <- client(Request.post(URL.root.port(port) / "increment", Body.empty)) body1 <- resp1.body.asString // Second request increments from 1 -> 2 resp2 <- client(Request.post(URL.root.port(port) / "increment", Body.empty)) body2 <- resp2.body.asString } yield (body1, body2) // test: ZIO[Client with Any with TestServer with Any & Scope, Throwable, (String, String)] = FlatMap( // trace = "repl.MdocSession.MdocApp15.test(test-server.md:359)", // first = Stateful( // trace = "repl.MdocSession.MdocApp15.test(test-server.md:359)", // onState = zio.FiberRef$unsafe$PatchFiber$$Lambda/0x00007f3ae293c000@6a379492 // ), // successK = // ) // Result: ("1", "2") ``` ## Integration with Other Types ### Within Module TestServer and TestClient serve complementary purposes. **`TestClient`** — TestServer and TestClient are complementary: - Use `TestServer` to test your **server** (routes, handlers) - Use `TestClient` to mock **external APIs** your code calls Example: Handler in TestServer calls an external API mocked by TestClient: ```scala val test = for { client <- ZIO.service[Client] port <- ZIO.serviceWithZIO[Server](_.port) // Mock external API with TestClient _ <- TestClient.addRequestResponse( Request.get(URL.root / "external-api"), Response.text("External data") ) // Handler in TestServer calls the mocked external API _ <- TestServer.addRoute { Method.GET / "data" -> handler { Response.text("External data") } } resp <- client(Request.get(URL.root.port(port) / "data")) } yield resp.status ``` **`TestChannel`** — For testing WebSocket endpoints served by TestServer: ```scala val test = for { client <- ZIO.service[Client] port <- ZIO.serviceWithZIO[Server](_.port) // WebSocket handler - would be added here // Method.GET / "ws" -> Handler.webSocket { channel => ... } // Client connects and exchanges messages via TestChannel } yield () ``` **`HttpTestAspect`** — Apply mode-dependent behavior testing: ```scala val test = for { client <- ZIO.service[Client] port <- ZIO.serviceWithZIO[Server](_.port) // Handler behavior depends on mode _ <- TestServer.addRoute { Method.GET / "status" -> handler { (_: Request) => ZIO.service[Server.Config].map { config => val mode = "Dev" // In real code, query mode from context Response.text(s"Mode: $mode") } } } } yield () ``` ### External Modules - **zio-http core** — TestServer uses `Routes`, `Handler`, `Request`, `Response`, `Client` from the main HTTP library - **zio** — Uses `ZIO`, `Ref`, `ZLayer`, `Scope` for effect management and resource control - **zio-http netty** — Uses `NettyDriver` and `NettyConfig` for the underlying HTTP implementation ## API Reference ### Public Methods | Method | Signature | Purpose | |--------|-----------|---------| | `TestServer#addRoute` | `[R] Route[R, Response] => ZIO[R, Nothing, Unit]` | Add single route to server | | `TestServer#addRoutes` | `[R] Routes[R, Response] => ZIO[R, Nothing, Unit]` | Add multiple routes to server | | `TestServer#addRequestResponse` | `Request, Response => ZIO[Any, Nothing, Unit]` | Add exact request/response mapping | | `TestServer#port` | `UIO[Int]` | Get server bound port | ### Companion Object Methods | Method | Signature | Purpose | |--------|-----------|---------| | `default` | `ZLayer[Any, Nothing, TestServer]` | Preconfigured server layer | | `TestServer.layer` | `ZLayer[Driver & Server.Config, Throwable, TestServer]` | Custom server layer | | `TestServer#addRoute` | `[R] Route[R, Response] => ZIO[R with TestServer, Nothing, Unit]` | Service method version of addRoute | | `TestServer#addRoutes` | `[R] Routes[R, Response] => ZIO[R with TestServer, Nothing, Unit]` | Service method version of addRoutes | | `TestServer#addRequestResponse` | `Request, Response => ZIO[TestServer, Nothing, Unit]` | Service method version | ## See Also - [TestClient](./test-client.md) — Mocking external HTTP dependencies - [TestChannel](./test-channel.md) — Testing WebSocket handlers - [HttpTestAspect](./http-test-aspect.md) — Testing mode-dependent behavior - [Testing Guide](../../guides/testing-http-apps) — Comprehensive testing strategies --- ## Securing Your APIs: Authentication with JWT Bearer and Refresh Tokens In the [previous guide](./authentication-with-jwt-bearer-tokens.md), we explored JWT bearer tokens and their role in modern API authentication. We examined their architectural elegance: stateless, self-contained tokens that eliminate database lookups. However, we also identified their fundamental limitation—once issued, a JWT remains valid until expiration. Revocation is impossible, modification is infeasible, and waiting for natural expiration is the only option. This creates a fundamental architectural dilemma. Short-lived tokens (5-15 minutes) provide strong security guarantees but degrade user experience through frequent re-authentication. Long-lived tokens (hours or days) improve usability but significantly increase security exposure. Consider a compromised token scenario or the need for immediate access revocation—neither case has an elegant solution with pure JWT implementations. Enter refresh tokens: the elegant solution that gives us both security and usability. ## The Token Expiration Dilemma Picture this scenario: You're building a mobile banking app. Security demands short token lifespans—maybe 5 minutes. But forcing users to enter their credentials every 5 minutes would be absurd. They'd abandon your app faster than you can say "authentication failed." Or consider the opposite: You issue tokens that last 30 days for a smooth user experience. Then an employee leaves the company. Their token remains valid for weeks, accessing sensitive data long after their departure. Your only option? Change the signing key and invalidate everyone's tokens. Mass logout. Support tickets flooding in. Not ideal. The JWT specification itself doesn't solve this problem. It gives us self-contained tokens but no mechanism for refreshing them. We need something more sophisticated—a two-token system that balances security with usability. ## Understanding Refresh Tokens Refresh tokens are long-lived credentials used solely to obtain new access tokens. Here's the beautiful simplicity: Your access token (the JWT) remains short-lived and stateless. When it expires, instead of forcing re-authentication, the client presents its refresh token to get a new access token. The refresh token itself never touches your API endpoints—it's only used at the token refresh endpoint. This separation of concerns is powerful: - **Access tokens** stay lightweight, short-lived, and stateless - **Refresh tokens** can be revocable, trackable, and managed server-side - **API endpoints** only deal with simple JWT validation - **Token refresh** happens transparently without user intervention ## How Refresh Tokens Work The refresh token flow adds sophistication to our authentication process without adding complexity to our API endpoints. Here's the complete lifecycle: ### 1. Initial Authentication When a user logs in successfully, the server generates two tokens: 1. **Access Token**: A short-lived JWT (5-15 minutes) containing user claims 2. **Refresh Token**: A long-lived token (days or weeks) stored server-side ```json { "accessToken": "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9...", "refreshToken": "550e8400-e29b-41d4-a716-446655440000", "tokenType": "Bearer", "expiresIn": 300 } ``` ### 2. Using Access Tokens The client includes the access token in API requests exactly as before: ``` Authorization: Bearer eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9... ``` API endpoints validate the JWT normally—they don't know or care about refresh tokens. This is the beauty of the pattern: your existing JWT infrastructure remains unchanged. ### 3. Token Refresh Process When the access token expires, the client sends the refresh token to a dedicated refresh endpoint: ``` POST /refresh Content-Type: application/x-www-form-urlencoded refreshToken=550e8400-e29b-41d4-a716-446655440000 ``` The server: 1. Validates the refresh token against its store 2. Checks if it's expired or revoked 3. Issues a new access token (and optionally a new refresh token) 4. Returns the new tokens to the client ### 4. Token Revocation When you need to revoke access, you simply delete the refresh token from server storage. Here's what happens: 1. User's refresh token is deleted from the database 2. Their current access token still works (but only for a few more minutes) 3. When the access token expires, they try to get a new one using their refresh token 4. The server can't find their refresh token in storage → Access denied 5. User must log in again The short-lived access token ensures revocation takes effect quickly—within minutes, not days. For example, if your access tokens lasted 30 days, revocation would be meaningless—the user could continue using their existing access token for weeks. But with 5-minute access tokens, even if someone has a valid token, when you revoke access, they'll be locked out within a 5-minute maximum. ## Security Considerations Refresh tokens introduce new security considerations. They're powerful, long-lived credentials that need careful handling. ### 1. Storage Security **Never store refresh tokens in localStorage or sessionStorage.** These are accessible to any JavaScript code, including XSS attacks. For web applications, use `httpOnly` cookies with `Secure` and `SameSite` flags. For mobile apps, use platform-specific secure storage (iOS Keychain, Android Keystore). ### 2. Refresh Token Rotation Each time a refresh token is used, issue a new one and invalidate the old one. This limits the window of opportunity for stolen tokens. If an attacker steals a refresh token but the legitimate user uses it first, the attacker's token becomes invalid: ```scala override def refreshTokens(refreshToken: String): Task[TokenResponse] = for { tokenData <- validateRefreshToken(refreshToken) _ <- revokeRefreshToken(refreshToken) // Invalidate old token newTokens <- issueTokens(tokenData.username, tokenData.email, tokenData.roles) } yield newTokens ``` ### 3. Detecting Token Theft Track refresh token usage patterns. If a refresh token is used twice (indicating both legitimate user and attacker have it), revoke all tokens for that user immediately. Force re-authentication to establish a new secure session. ## Implementation Let's build a complete refresh token system using ZIO HTTP. We'll extend our JWT service to manage both access and refresh tokens. ### Token Response Model First, define the structure for our token response: ```scala case class TokenResponse( accessToken: String, refreshToken: String, tokenType: String = "Bearer", expiresIn: Int = 300 ) object TokenResponse { implicit val codec: JsonCodec[TokenResponse] = DeriveJsonCodec.gen } ``` The response includes both tokens, the token type (always "Bearer" for our implementation), and the access token's lifetime in seconds. ### Enhanced JWT Service Our JWT service now manages both token types: ```scala trait JwtTokenService { def issueTokens(username: String, email: String, roles: Set[String]): UIO[TokenResponse] def verifyAccessToken(token: String): Task[UserInfo] def refreshTokens(refreshToken: String): Task[TokenResponse] def revokeRefreshToken(refreshToken: String): UIO[Unit] } ``` Notice the separation of concerns: - `issueTokens` creates both tokens during login - `verifyAccessToken` validates JWTs (unchanged from before) - `refreshTokens` exchanges refresh tokens for new access tokens - `revokeRefreshToken` handles logout and security revocations ### Refresh Token Storage Unlike stateless JWTs, refresh tokens need server-side storage. We'll use an in-memory store for simplicity, but production systems should use Redis, a database, or another persistent store: ```scala case class RefreshTokenData( username: String, email: String, roles: Set[String], expiresAt: Long ) case class JwtTokenServiceLive( secretKey: Secret, accessTokenTTL: Duration, refreshTokenTTL: Duration, algorithm: JwtHmacAlgorithm, refreshTokenStore: Ref[Map[String, RefreshTokenData]] ) extends JwtTokenService { private def generateRefreshToken( username: String, email: String, roles: Set[String] ): UIO[String] = for { tokenId <- generateSecureToken expiresAt = System.currentTimeMillis() + refreshTokenTTL.toMillis _ <- refreshTokenStore.update( _.updated(tokenId, RefreshTokenData(username, email, roles, expiresAt)) ) } yield tokenId private def generateSecureToken: UIO[String] = ZIO.succeed { val random = new SecureRandom() val bytes = new Array[Byte](32) random.nextBytes(bytes) java.util.Base64.getUrlEncoder.withoutPadding.encodeToString(bytes) } } ``` In this implementation, refresh tokens are cryptographically secure random strings with 256 bits of entropy, Base64URL-encoded for safe transport. This provides strong unpredictability and resistance to brute force attacks. ### The Refresh Endpoint The refresh endpoint validates the refresh token and issues new tokens: ```scala Method.POST / "refresh" -> handler { (request: Request) => for { form <- request.body.asURLEncodedForm refreshToken <- extractFormField(form, "refreshToken") tokenService <- ZIO.service[JwtTokenService] newTokens <- tokenService .refreshTokens(refreshToken) .orElseFail(Response.unauthorized("Invalid or expired refresh token")) response = Response.json(newTokens.toJson) } yield response } ``` The implementation checks the refresh token store, validates the refresh token, and issues fresh tokens: ```scala override def refreshTokens(refreshToken: String): Task[TokenResponse] = for { store <- refreshTokenStore.get tokenData <- ZIO .fromOption(store.get(refreshToken)) .orElseFail(new Exception("Invalid refresh token")) _ <- ZIO.when(tokenData.expiresAt < System.currentTimeMillis()) { ZIO.fail(new Exception("Refresh token expired")) } // Revoke old refresh token and issue new tokens _ <- refreshTokenStore.update(_ - refreshToken) newTokens <- issueTokens(tokenData.username, tokenData.email, tokenData.roles) } yield newTokens ``` This implements refresh token rotation—each use generates a new refresh token and invalidates the old one. ### Logout Implementation Logout becomes trivial with refresh tokens—just remove them from the store: ```scala Method.POST / "logout" -> handler { (request: Request) => for { form <- request.body.asURLEncodedForm refreshToken <- extractFormField(form, "refreshToken") tokenService <- ZIO.service[JwtTokenService] _ <- tokenService.revokeRefreshToken(refreshToken) } yield Response.text("Logged out successfully") } ``` The user's access token might remain valid for a few more minutes, but without a refresh token, they can't get new ones. The session effectively ends. ## Client-Side Token Management Clients need sophisticated token management to handle refresh tokens properly. The key is making token refresh transparent—API calls should "just work" even when tokens expire. ### ZIO HTTP Client A simple client interface might look like this, which includes four operations: ```scala trait AuthenticationService { def login(username: String, password: String): IO[Throwable, TokenResponse] def refreshTokens(refreshToken: String): IO[Throwable, TokenResponse] def makeAuthenticatedRequest(request: Request): IO[Throwable, Response] def logout(refreshToken: String): IO[Throwable, Unit] } ``` 1. `login` obtains both issued tokens 2. `refreshTokens` exchanges refresh tokens for new access tokens 3. `makeAuthenticatedRequest` handles HTTP requests by transparently refreshing tokens as needed 4. `logout` revokes the refresh token The `login`, `logout`, and `refreshTokens` functions are straightforward. The key function is `makeAuthenticatedRequest`, which takes a request and automatically handles token expiration and refresh: ```scala case class AuthenticationServiceLive( client: Client, tokenStore: Ref[Option[TokenStore]], ) extends AuthenticationService { def login(username: String, password: String): IO[Throwable, TokenResponse] = ??? def refreshTokens(refreshToken: String): IO[Throwable, TokenResponse] = ??? def makeAuthenticatedRequest(request: Request): IO[Throwable, Response] = { def attemptRequest(accessToken: String): IO[Throwable, Response] = client.batched(request.addHeader(Header.Authorization.Bearer(accessToken))) def refreshAndRetry(currentTokenStore: TokenStore): IO[Throwable, Response] = for { _ <- Console.printLine("Access token expired, refreshing...") newTokens <- refreshTokens(currentTokenStore.refreshToken) response <- attemptRequest(newTokens.accessToken) } yield response tokenStore.get.flatMap { case Some(tokens) => attemptRequest(tokens.accessToken).flatMap { response => if (response.status == Status.Unauthorized) { refreshAndRetry(tokens) } else { ZIO.succeed(response) } } case None => ZIO.fail(new Exception("No authentication tokens available")) } } def logout(refreshToken: String): IO[Throwable, Unit] = ??? } ``` The client: 1. Attempts the request with the current access token 2. If it gets 401 Unauthorized, refreshes the token 3. Retries with the new access token 4. Updates the token store for future requests Using this approach, token refresh is seamless. The user experience remains smooth, and the client handles token expiration gracefully. ### JavaScript Client Implementation #### Login Form The login form is the same as in the previous guide: ```html Login Form Actions ``` It takes username and password, and calls the `/login` endpoint to get both access and refresh tokens. Here is the login function: ```javascript const SERVER_URL = 'http://localhost:8080'; let accessToken = null; let refreshToken = null; let tokenExpiryTime = null; async function login() { const username = document.getElementById('username').value; const password = document.getElementById('password').value; try { const response = await fetch(SERVER_URL + '/login', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ username, password }) }); if (response.ok) { const tokens = await response.json(); accessToken = tokens.accessToken; refreshToken = tokens.refreshToken; tokenExpiryTime = new Date(Date.now() + (tokens.expiresIn * 1000)); } else { clearTokens(); } } catch (error) { log(`ERROR: ${error.message}`); } } ``` The `login` function stores both tokens and the access token's expiry time. #### Making Authenticated Requests Making authenticated requests is similar to the previous article. The key difference is handling 401 responses by refreshing the token. When a request fails due to an expired access token, the client calls the refresh endpoint with the stored refresh token, updates its tokens, and retries the original request: ```javascript async function userProfile() { try { const url = '/profile/me'; const headers = { 'Authorization': `Bearer ${accessToken}` }; const response = await fetch(url, { method: 'GET', headers: headers }); const text = await response.text(); if (response.ok) { displayResponse('protectedResponse', text); } else if (response.status === 401) { await refreshTokens(); if (accessToken) { setTimeout(() => userProfile(), 1000); } } else { console.log(`Error: ${text}`) } } catch (error) { console.log(`Network error: ${error.message}`); } } ``` The refresh logic itself is straightforward: ```javascript async function refreshTokens() { try { const formData = new URLSearchParams(); formData.append('refreshToken', refreshToken); const response = await fetch(SERVER_URL + '/refresh', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: formData }); if (response.ok) { const tokens = await response.json(); accessToken = tokens.accessToken; refreshToken = tokens.refreshToken; tokenExpiryTime = new Date(Date.now() + (tokens.expiresIn * 1000)); } else { const error = await response.text(); console.log(`FAILED: ${error}`); clearTokens(); } } catch (error) { console.log(`ERROR: ${error.message}`); } } function clearTokens() { accessToken = null; refreshToken = null; tokenExpiryTime = null; } ``` This implementation ensures that the client always has valid tokens when making requests. If the access token expires, it uses the refresh token to get a new one without user intervention. However, the problem is we have to implement the same refresh logic for all protected endpoints. Instead of doing that, we can create a generic function to make authenticated requests that handles refreshing the token automatically: ```javascript const SERVER_URL = 'http://localhost:8080'; let accessToken = null; let refreshToken = null; let tokenExpiryTime = null; async function makeAuthenticatedRequest(url, options = {}) { const attemptRequest = async (token) => { return await fetch(url, { ...options, headers: { ...options.headers, 'Authorization': `Bearer ${token}` } }); }; const refreshAndRetry = async () => { const formData = new URLSearchParams(); formData.append('refreshToken', refreshToken); const response = await fetch(SERVER_URL + '/refresh', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: formData }); if (!response.ok) { const error = await response.text(); throw new Error(`Token refresh failed: ${error}`); } const tokens = await response.json(); accessToken = tokens.accessToken; refreshToken = tokens.refreshToken; tokenExpiryTime = new Date(Date.now() + (tokens.expiresIn * 1000)); return await attemptRequest(accessToken); }; // Check if we have tokens if (!accessToken || !refreshToken) { throw new Error('No authentication tokens available. Please login first.'); } // First attempt with current access token const response = await attemptRequest(accessToken); // If unauthorized, refresh and retry if (response.status === 401) { return await refreshAndRetry(); } return response; } ``` This makes the client code cleaner and easier to maintain. All authenticated requests go through this function, which handles token expiration and refresh seamlessly. For example, to fetch the user profile, we simply call: ```javascript const response = await makeAuthenticatedRequest(SERVER_URL + '/profile/me'); ``` Please note that the approach we used for token refresh is **reactive**—the client only attempts to refresh when it receives a 401 Unauthorized response. This keeps the implementation simple and avoids unnecessary refresh calls. We can also **proactively** monitor token expiration and refresh before it happens. This is more complex but can improve user experience by avoiding failed requests. ## Best Practices ### Token Lifetimes Finding the right token lifetimes requires balancing security and usability: - **Access tokens**: 5-15 minutes for high-security applications, up to 1 hour for lower-risk scenarios - **Refresh tokens**: 7-30 days for typical applications, hours for high-security environments Consider your threat model. Banking apps might use 5-minute access tokens and 1-hour refresh tokens, requiring frequent re-authentication. Social media apps might use 1-hour access tokens and 30-day refresh tokens for convenience. ### Refresh Token Families Track refresh token "families"—chains of tokens descended from the same login. If you detect reuse of an old family member, revoke the entire family. This catches token theft even with rotation: ```scala case class RefreshTokenData( username: String, email: String, roles: Set[String], expiresAt: Long, familyId: String, // Track token families sequence: Int // Position in family chain ) ``` ### Device-Specific Tokens In multi-device scenarios, issue separate refresh tokens per device: ```scala case class RefreshTokenData( username: String, email: String, roles: Set[String], expiresAt: Long, deviceId: String, deviceName: String ) ``` Users can revoke access to specific devices without affecting others. Lost phone? Revoke its tokens without logging out everywhere. ## Source Code The complete source code for this JWT Bearer and Refresh Token Authentication example is available in the ZIO HTTP repository. To clone the example: ```bash git clone --depth 1 --filter=blob:none --sparse https://github.com/zio/zio-http.git cd zio-http git sparse-checkout set zio-http-example-jwt-bearer-refresh-token-auth ``` ### Running the Server To run the authentication server: ```bash cd zio-http/zio-http-example-jwt-bearer-refresh-token-auth sbt "runMain example.auth.bearer.jwt.refresh.AuthenticationServer" ``` The server starts on `http://localhost:8080` with these test users: | Username | Password | Email | Roles | |----------|---------------|----------------------|--------------| | `john` | `password123` | john@example.com | user | | `jane` | `secret456` | jane@example.com | user | | `admin` | `admin123` | admin@company.com | admin, user | ### ZIO HTTP Client Run the command-line client (ensure server is running): ```bash cd zio-http/zio-http-example-jwt-bearer-refresh-token-auth sbt "runMain example.auth.bearer.jwt.refresh.AuthenticationClient" ``` ### Web-Based Client To demonstrate the refresh token authentication flow in a web client, we've created a simple HTML page where users can log in, view their profile, refresh tokens, and log out. First, start the `AuthenticationServer`, which provides the authentication API and serves the HTML client (`jwt-client-with-refresh-token.html`) located in the resource folder: ```bash sbt "runMain example.auth.bearer.jwtrefresh.AuthenticationServer" ``` Then open [http://localhost:8080](http://localhost:8080) in your browser to interact with the system using predefined credentials. You can log in, view your profile, manually refresh tokens, and log out, showcasing the full JWT bearer and refresh token authentication flow with automatic token refresh on expired access tokens. The HTML file's source code can be found in the example project's resource folder. ## Demo We have deployed a live demo of the server and the web client at: [http://jwt-bearer-refresh-token-auth-demo.ziohttp.com/](http://jwt-bearer-refresh-token-auth-demo.ziohttp.com/) The demo allows you to experience the refresh token authentication flow firsthand. You can log in using the predefined users, access their profiles, observe automatic token refresh when access tokens expire, and log out to see how token revocation works in practice. HTTP transactions can be inspected at the bottom of the page, so you can see the requests and responses in detail, including the token refresh mechanism in action. ## Conclusion Refresh tokens elegantly solve JWT's fundamental limitation. They give us stateless, scalable authentication while maintaining control over session lifecycle. The pattern's beauty lies in its simplicity: short-lived access tokens for security, long-lived refresh tokens for usability, and clean separation between them. Key takeaways from implementing refresh tokens: **The two-token system works**: Access tokens remain simple, stateless JWTs. Refresh tokens add revocability without complicating your API. Your endpoints still just validate JWTs—they don't know refresh tokens exist. **Security improves dramatically**: Five-minute access tokens become practical when users don't have to re-authenticate constantly. Immediate revocation becomes possible. Token theft has limited impact. You get fine-grained session control without sacrificing user experience. **Server-side storage is required**: Refresh tokens need server-side storage, but the data is small and operations are simple. Redis, a database table, or even a distributed cache works fine. The storage overhead is negligible compared to the security benefits. Refresh tokens aren't perfect—they add complexity, require storage, and need careful implementation. But for applications that need both security and usability, they're the gold standard. They turn JWT's greatest weakness into a manageable tradeoff, giving us the best of both worlds: stateless scalability with stateful control. --- ## Securing Your APIs: Authentication with JWT Bearer Tokens Self-contained tokens are authentication tokens that carry all the necessary information within themselves, eliminating the need for server-side storage or database lookups during validation. Unlike traditional session identifiers that merely point to data stored on the server, self-contained tokens embed the actual user information, permissions, and metadata directly within the token structure. Think of it this way: a session ID is like a driver's license—it contains all the relevant information right there on the card itself. A JSON Web Token (JWT) is the most widely adopted standard for self-contained tokens. It packages user claims and metadata into a compact, URL-safe string that consists of three parts: a header (describing the token type and signing algorithm), a payload (containing the actual claims), and a signature (ensuring the token's integrity). ## The evolution from session-based to token-based authentication ### The Session Era In the early days of web applications, session-based authentication reigned supreme. The flow was straightforward: 1. User logs in with credentials 2. Server creates a session and stores it in memory or database 3. Server sends a session ID to the client (usually as a cookie) 4. Client includes session ID with every request 5. Server looks up session data for each request This approach worked well for monolithic applications running on single servers. However, as applications grew more complex, several challenges emerged: - **Scalability issues**: Every authenticated request required a database lookup or shared memory access - **Server affinity**: Load-balanced environments needed sticky sessions or distributed session stores - **Cross-domain limitations**: Cookies don't work well across different domains - **Mobile app friction**: Native mobile apps don't handle cookies as naturally as browsers ### The Shift to Stateless Authentication As RESTful APIs and microservices architectures gained popularity, the industry needed a more scalable, stateless approach. Token-based authentication emerged as the solution, with JWTs becoming the de facto standard by offering: - **Statelessness**: No server-side session storage required - **Decentralization**: Any server can verify a token without accessing a central store - **Cross-domain friendly**: Tokens work seamlessly across different domains and platforms - **Mobile-ready**: Easy to implement in native mobile applications ## When JWTs are the Right Choice Picture building a modern RESTful API for a React application. This is JWT territory. The stateless nature aligns perfectly with REST principles, and your frontend can store and send tokens without wrestling with cookies or CORS. In microservices architectures, JWTs truly shine. Your authentication service issues a token, and every other service—user profiles, orders, notifications—validates it independently using a shared secret. Beautiful simplicity. For temporary operations like password resets or file download links, JWTs provide elegant solutions. Embed the expiration and permissions in the token itself. When it expires, access stops—no cleanup required. And when providing API access to external consumers, JWTs offer a clean, standard approach without exposing internal session mechanisms. ## When to Reconsider JWTs But JWTs aren't always the answer. Sometimes they create more problems than they solve. Need immediate revocation for security incidents? JWTs remain valid until expiration. You could maintain a blacklist, but then you've reinvented sessions with extra steps. Thinking about storing sensitive data? Don't. JWTs are encoded, not encrypted—anyone can decode them. Watch the token size too. If your JWT grows to several kilobytes with extensive permissions and preferences, you're adding that overhead to every single request. A simple session ID might serve you better. For traditional server-rendered applications, JWTs often add unnecessary complexity. Cookie-based sessions work naturally with form submissions and page navigations—why complicate things using JWT tokens? When permissions must change instantly, session-based systems offer better control. You modify server-side data, and changes take effect immediately. With JWTs, you're waiting for expiration or building complex revocation systems. ## Anatomy of a JWT (Header, Payload, Signature) If you open any JWT in a decoder, you'll see something that looks like this: three chunks of gibberish separated by dots. Something like `eyJhbGciOiJIUzI1NiIs...`. But there's a beautiful structure underneath. A JWT consists of three distinct parts, joined together with periods: `header.payload.signature`. Each part serves a specific purpose, and together they create a self-contained, verifiable token. ### 1. The Header The header is JWT's metadata—it tells receivers how to handle the token. Typically, it contains just two pieces of information: the token type (JWT) and the signing algorithm (like HS256 or RS256). ```json { "alg": "HS256", "typ": "JWT" } ``` This JSON object gets Base64URL-encoded to become the first part of your token. It's like the envelope of a letter, telling the recipient how to open and verify what's inside. ### 2. The Payload The payload is where the magic happens—it contains the actual **claims** about the user and additional metadata. These claims might include who the user is (`subject`), when the token was issued (`iat`), when it expires (`exp`), and any custom data your application needs: ```json { "sub": "1234567890", "name": "Jane Doe", "email": "jane@example.com", "roles": ["admin", "user"], // e.g. of custom field "iat": 1516239022, "exp": 1516242622 } ``` The payload carries your application's truth about the authenticated user. It's Base64URL-encoded but not encrypted—anyone can decode and read it. This transparency is by design, not oversight. ### 3. The Signature The signature is JWT's tamper-proof seal. It's created by taking the encoded header, the encoded payload, a **secret key**, and running them through the algorithm specified in the header. ``` HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret ) ``` This signature ensures that if anyone modifies the header or payload, the signature won't match, and token validation will fail. It's the cryptographic guarantee that the token hasn't been tampered with since its creation. ## How JWTs work under the hood Understanding JWT mechanics helps you appreciate both their elegance and their limitations. When a server creates a JWT, it starts by constructing the header and payload JSON objects. These get Base64URL-encoded separately, creating the first two parts of the token. Then comes the crucial step: the server takes these encoded strings, concatenates them with a period, and feeds them along with a secret key into a cryptographic signing algorithm. The resulting signature becomes the third part of the token. The complete token—header.payload.signature—gets sent to the client. The client doesn't need to understand any of this; it just stores the token and sends it back with future requests, typically in an Authorization header: `Bearer eyJhbGciOiJIUzI1NiIs...`. When the server receives a JWT, the verification process runs in reverse. It splits the token at the periods, extracting the three parts. It decodes the header to determine which algorithm to use. Then—and this is the critical part—it takes the received header and payload, signs them again with its secret key, and compares this new signature with the signature that came with the token. If they match, the token is valid and unmodified. The beauty lies in the mathematics: without the secret key, it's computationally infeasible to create a valid signature. Even changing a single character in the payload would produce a completely different signature. This is how JWTs achieve integrity without encryption. But here's what catches developers off guard: the server doesn't store these tokens anywhere. There's no database table of valid tokens, no session store to query. The token's validity comes entirely from its signature. If the signature checks out and the token hasn't expired, it's valid—period. This statelessness is both JWT's greatest strength and its most significant limitation. ## The Difference Between Encoding, Encryption, and Signing These three concepts get confused constantly, but understanding their differences is crucial for JWT security. - **Encoding** is simply changing data's representation. It's like translating a book—the information remains the same, just in a different format. Base64URL encoding transforms JSON into URL-safe strings. There's no secret involved; anyone can encode or decode. It's about compatibility, not security. When you Base64URL-encode the JWT payload, you're not hiding anything—you're just making it transport-friendly. - **Encryption** scrambles data so only authorized parties can read it. It's like putting your message in a locked safe—without the key, the contents are meaningless gibberish. Encryption requires secrets (keys) and protects confidentiality. Standard JWTs don't use encryption; the payload is merely encoded. Anyone who intercepts a JWT can read its contents. This is why you should never put passwords or sensitive data in JWT payloads. - **Signing** creates a cryptographic fingerprint of data. It doesn't hide the data but proves it hasn't been tampered with and confirms who created it. Think of it like a seal on a letter—the letter is still readable, but the unbroken seal proves authenticity and integrity. This is what the JWT signature provides. The signature says: "This token was created by someone with the secret key, and nobody has modified it since." The confusion often comes from expecting JWTs to provide all three. In reality, standard JWTs only provide encoding (for transport) and signing (for integrity). They don't provide encryption (for confidentiality). Your JWT payload is visible to anyone who intercepts it—the signature only prevents them from modifying it. This is why HTTPS is non-negotiable when using JWTs. HTTPS provides the encryption layer, protecting tokens during transmission. The JWT signature ensures integrity, HTTPS ensures confidentiality, and together they provide complete security. If you absolutely need encrypted payloads, JSON Web Encryption (JWE) exists, but it adds significant complexity. Most applications find that the combination of signed JWTs over HTTPS provides the right balance of security and simplicity. ## Symmetric vs. Asymmetric Signing When it comes to signing JWTs, you have two main approaches: symmetric and asymmetric signing. Each has its own use cases, advantages, and trade-offs. ### Symmetric Signing Symmetric signing uses a single secret key for both creating and verifying the JWT signature. The same key must be shared between the issuer (the server that creates the token) and the verifier (the server that checks the token). In symmetric signing, algorithms like HMAC with SHA-256 (HS256) are commonly used. The process is straightforward: 1. The server generates a secret key and keeps it safe. 2. When creating a JWT, the server uses this secret key to sign the token. 3. When verifying the JWT, the server uses the same secret key to validate the signature. 4. If the signature matches, the token is valid. 5. If the signature doesn't match, the token has been tampered with or is invalid. The main advantage of symmetric signing is its simplicity and speed. Since only one key is involved, it's easy to implement and efficient to compute. However, the downside is that both the issuer and verifier must securely share and manage the same secret key. If the key is compromised, anyone with access to it can create valid tokens. ### Asymmetric Signing Asymmetric signing, on the other hand, uses a pair of keys: a private key for signing and a public key for verification. The private key is kept secret by the issuer, while the public key can be freely shared with anyone who needs to verify the token. In asymmetric signing, algorithms like RSA (RS256) or ECDSA (ES256) are commonly used. The process works as follows: 1. The server generates a key pair: a private key and a public key. 2. When creating a JWT, the server uses the private key to sign the token. 3. When verifying the JWT, the server (or any other party) uses the public key to validate the signature. 4. If the signature matches, the token is valid. 5. If the signature doesn't match, the token has been tampered with or is invalid. The main advantage of asymmetric signing is enhanced security and flexibility. Since the private key never leaves the issuer, it reduces the risk of compromise. Additionally, multiple verifiers can validate tokens using the public key without needing access to the private key. This is particularly useful in distributed systems or microservices architectures where different services need to verify tokens issued by a central authority. However, asymmetric signing is more complex to implement and generally slower than symmetric signing due to the computational overhead of public-key cryptography. ### Choosing Between Symmetric and Asymmetric Signing The choice between symmetric and asymmetric signing depends on your application's requirements: - **Use Symmetric Signing (e.g., HS256)** when: - You have a simple architecture with a single service issuing and verifying tokens. - Performance is a critical concern, and you need fast token processing. - You can securely manage and share the secret key between the issuer and verifier. - **Use Asymmetric Signing (e.g., RS256, ES256)** when: - You have a distributed system or microservices architecture where multiple services need to verify tokens. - You want to minimize the risk of key compromise by keeping the private key secure and only sharing the public key. - You require a higher level of security and flexibility in token verification. ## Implementation ### JSON Web Token Service We need a service to issue and verify JWT tokens. Let's define an interface for such a service: ```scala trait JwtTokenService { def issue(username: String): UIO[String] def verify(token: String): Task[String] } ``` The `issue` method takes a `username` and generates a JWT token. It uses the standard `sub` claim to store the username. The `verify` method takes a JWT token and decodes it, returning the username if the token is valid, or failing if the token is invalid or expired. ### Implementing the JWT Service To implement the `JwtTokenService`, we can use the `jwt-core` library: ```sbt libraryDependencies += "com.github.jwt-scala" %% "jwt-core" % 11.0.4 ``` It supports both symmetric and asymmetric signing. The following implementation uses a symmetric signing algorithm: ```scala case class JwtAuthServiceLive( secretKey: Secret, tokenTTL: Duration, algorithm: JwtHmacAlgorithm, ) extends JwtTokenService { implicit val clock: Clock = Clock.systemUTC override def issue(username: String): UIO[String] = ZIO.succeed { Jwt.encode( JwtClaim(subject = Some(username)).issuedNow.expiresIn(tokenTTL.toSeconds), secretKey.stringValue, algorithm, ) } override def verify(token: String): Task[String] = ZIO .fromTry( Jwt.decode(token, secretKey.stringValue, Seq(algorithm)), ) .map(_.subject) .some .orElse(ZIO.fail(new Exception("Invalid token"))) } object JwtTokenService { def live( secretKey: Secret, tokenTTL: Duration = 15.minutes, algorithm: JwtHmacAlgorithm = JwtAlgorithm.HS512, ): ULayer[JwtAuthServiceLive] = ZLayer.succeed(JwtAuthServiceLive(secretKey, tokenTTL, algorithm)) } ``` The `JwtAuthServiceLive` class implements the `JwtTokenService` interface. It uses a `secretKey` to sign and verify tokens, a `tokenTTL` to set the token expiration time, and an `algorithm` to specify the signing algorithm. ### Authentication Middleware Now that we have a JWT service, we can create an authentication middleware that verifies the JWT token in the `Authorization` header of incoming requests: ```scala object AuthMiddleware { def jwtAuthentication(realm: String): HandlerAspect[JwtTokenService & UserService, UserInfo] = HandlerAspect.interceptIncomingHandler { handler { (request: Request) => request.header(Header.Authorization) match { case Some(Header.Authorization.Bearer(token)) => ZIO .serviceWithZIO[JwtTokenService](_.verify(token.value.asString)) .flatMap { username => ZIO.serviceWithZIO[UserService](_.getUser(username)) } .map(u => UserInfo(u.username, u.email, u.roles)) .map(userInfo => (request, userInfo)) .orElseFail( Response.unauthorized.addHeaders(Headers(Header.WWWAuthenticate.Bearer(realm))), ) case _ => ZIO.fail(Response.unauthorized.addHeaders(Headers(Header.WWWAuthenticate.Bearer(realm)))) } } } } ``` The `jwtAuth` method creates a middleware that intercepts incoming requests. It checks for the `Authorization` header and extracts the JWT token. It then uses the `JwtTokenService` to verify the token and retrieve the username. If the token is valid, it fetches the user details from the `UserService` and passes them along with the request to the next handler. If the token is invalid or missing, it responds with a `401 Unauthorized` status including the `WWW-Authenticate` header with the realm of the resource that requires authentication. Please note that the `UserService` is the same as defined in the [digest authentication guide](digest-authentication.md). Also please note that we've introduced a new data type called `UserInfo` that encapsulates all necessary user information to be passed to the next handlers in the request pipeline: ```scala case class UserInfo( username: String, email: String, roles: Set[String], ) object UserInfo { implicit val codec: JsonCodec[UserInfo] = DeriveJsonCodec.gen } ``` ### The Login Route The login process involves the following steps: 1. The server receives the username and password from the client. 2. The server verifies the credentials against the user store. 3. If the credentials are valid, the server issues a JWT token using the `JwtTokenService`. 4. The server responds with the JWT token to the client. The login route requires two services: `UserService` to verify the user credentials and `JwtTokenService` to issue the JWT token. Here's how we can implement the login route: ```scala val login = Method.POST / "login" -> handler { (request: Request) => def extractFormField(form: Form, fieldName: String): ZIO[Any, Response, String] = ZIO .fromOption(form.get(fieldName).flatMap(_.stringValue)) .orElseFail(Response.badRequest(s"Missing $fieldName")) val unauthorizedResponse = Response .unauthorized("Invalid username or password.") .addHeaders(Headers(Header.WWWAuthenticate.Bearer("User Login"))) for { form <- request.body.asURLEncodedForm.orElseFail(Response.badRequest) username <- extractFormField(form, "username") password <- extractFormField(form, "password") tokenService <- ZIO.service[JwtTokenService] user <- ZIO .serviceWithZIO[UserService](_.getUser(username)) .orElseFail(unauthorizedResponse) response <- if (user.password == Secret(password)) tokenService.issue(username).map(Response.text(_)) else ZIO.fail(unauthorizedResponse) } yield response } ``` The `login` route listens for `POST` requests at the `/login` endpoint. It extracts the `username` and `password` from the request body, verifies the credentials using the `UserService`, and if valid, issues a JWT token using the `JwtTokenService`. The token is then returned in the response body. If the credentials are invalid or missing, it responds with a `401 Unauthorized` status. ### Applying the Middleware After user login, we are ready to protect our routes using the `jwtAuth` middleware. For example, we can protect the `/profile/me` route using the `@@` syntax: ```scala val profile = Method.GET / "profile" / "me" -> handler { (_: Request) => ZIO.serviceWith[UserInfo](info => Response.text(s"Welcome ${info.username}!")) } @@ jwtAuth(realm = "User Profile") ``` The `profile` route listens for `GET` requests at the `/profile/me` endpoint. It uses the `jwtAuth` middleware to ensure that only authenticated users can access this route. If the user is authenticated, it retrieves the user's details and responds with a welcome message including the user's profile information. If the user is not authenticated, it responds with a `401 Unauthorized` status. ## Restricting Access Based on User Roles In many applications, we need to restrict access to certain routes based on user roles or permissions. For example, we can create an `/admin` route that only allows access to users with the `admin` role. There are two main approaches to implement role-based access control with JWTs: 1. The traditional approach, where the JWT token only contains the username, and the API service retrieves the user's roles from a user service or database for each request. 2. An enhanced approach, where the JWT token includes all necessary user information (such as roles) in its claims, allowing the API service to make authorization decisions without additional lookups. ### Traditional Approach: Server-Side Role Verification In this scenario, the `jwtAuth` middleware retrieves the authenticated username from the JWT token and then fetches the user details from the `UserService` and passes the user details (the `User` object) to the next handlers in the request pipeline. The admin route checks if the user has the `admin` role and grants or denies access accordingly: ```scala val admin = Method.GET / "admin" / "users" -> handler { (_: Request) => Handler.fromZIO(ZIO.service[UserService]).flatMap { userService => Handler.fromZIO { ZIO.serviceWithZIO[UserInfo] { info => if (info.roles.contains("admin")) userService.getUsers.map { users => val userList = users.map(u => s"${u.username} (${u.email}) - Role: ${u.roles}").mkString("\n") Response.text(s"User List:\n$userList") } else ZIO.fail(Response.unauthorized(s"Access denied. User ${info.username} is not an admin.")) } } @@ jwtAuth(realm = "Admin Area") } } ``` In this authentication pattern, we only include the username as a JWT claim. Additional information, such as the user's role, is not included in the JWT token. Therefore, it is the responsibility of the middleware to fetch the complete user details from the `UserService` after verifying the token. While this approach works well for monolithic applications, it presents challenges in microservice architectures. Consider a scenario where administrative operations are handled by a separate, independent service. How can this microservice verify if the requesting user has admin privileges? Using the traditional approach, all protected routes using the `jwtAuth` middleware would require the `UserService` to verify user roles. With the traditional approach, the microservice must make an API call to the `UserService` to verify the user's roles. Although functional, this creates an unnecessary dependency—the microservice becomes coupled to the `UserService` solely for role verification. ### Enhanced Approach: Encoding Roles in JWT Claims A more efficient approach is to encode all required information directly in the JWT claims. When a user initially authenticates through the login API, the server includes the necessary fields (such as roles) in the token's claims. In subsequent requests to protected areas, all essential user information is already encoded within the token. Let's revise the `jwtAuth` middleware to implement this approach. The original middleware had the type `HandlerAspect[JwtTokenService & UserService, UserInfo]`, which required calling the `UserService` after verifying the JWT token to retrieve user details. In our new implementation, we eliminate the dependency on `UserService` since all required fields are contained within the JWT claims: ```scala object AuthMiddleware { def jwtAuth(realm: String): HandlerAspect[JwtTokenServiceClaim, UserInfo] = HandlerAspect.interceptIncomingHandler { handler { (request: Request) => request.header(Header.Authorization) match { case Some(Header.Authorization.Bearer(token)) => ZIO .serviceWithZIO[JwtTokenServiceClaim](_.verify(token.value.asString)) .map(userInfo => (request, userInfo)) .orElseFail( Response .unauthorized("Invalid or expired token.") .addHeaders(Headers(Header.WWWAuthenticate.Bearer(realm))), ) case _ => ZIO.fail(Response.unauthorized.addHeaders(Headers(Header.WWWAuthenticate.Bearer(realm)))) } } } } ``` The middleware now verifies the received token, extracts the user information, and passes it to subsequent handlers in the request pipeline. Note that the middleware type has changed to `HandlerAspect[JwtTokenService, UserInfo]`, eliminating the `UserService` dependency. ### Updating the JWT Service We need to update the `JwtTokenService` interface to support the enhanced claims: ```scala trait JwtTokenService { def issue(username: String, email: String, roles: Set[String]): UIO[String] def verify(token: String): Task[UserInfo] } ``` The `issue` method now accepts additional parameters (email and roles), and the `verify` method returns a `UserInfo` object instead of just the username. The `JwtTokenService` implementation must be updated to include additional fields in the claims: ```scala case class JwtAuthServiceLive( secretKey: Secret, tokenTTL: Duration, algorithm: JwtHmacAlgorithm, ) extends JwtTokenServiceClaim { implicit val clock: Clock = Clock.systemUTC override def issue(username: String, email: String, roles: Set[String]): UIO[String] = ZIO.succeed { Jwt.encode( claim = JwtClaim(subject = Some(username)).issuedNow .expiresIn(tokenTTL.toSeconds) .++(("roles", roles)) .++(("email", email)), key = secretKey.stringValue, algorithm = algorithm, ) } override def verify(token: String): ZIO[Any, Throwable, UserInfo] = ZIO .fromTry( Jwt.decode(token, secretKey.stringValue, Seq(algorithm)), ) .filterOrFail(_.isValid)(new Exception("Token expired")) .map(_.toJson) .map(UserInfo.codec.decodeJson(_).toOption) .someOrFail(new Exception("Invalid token")) } object JwtTokenServiceClaim { def live( secretKey: Secret, tokenTTL: Duration = 15.minutes, algorithm: JwtHmacAlgorithm = JwtAlgorithm.HS512, ): ULayer[JwtAuthServiceClaimLive] = ZLayer.succeed(JwtAuthServiceClaimLive(secretKey, tokenTTL, algorithm)) } ``` ### Updating Routes The login route must be updated to pass additional fields to the `issue` method: ```scala val login = Method.POST / "login" -> handler { (request: Request) => for { form <- request.body.asURLEncodedForm.orElseFail(Response.badRequest) username <- extractFormField(form, "username") password <- extractFormField(form, "password") tokenService <- ZIO.service[JwtTokenServiceClaim] user <- ZIO .serviceWithZIO[UserService](_.getUser(username)) .orElseFail(unauthorizedResponse) response <- if (user.password == Secret(password)) tokenService.issue(username, user.email, user.roles).map(Response.text(_)) else ZIO.fail(unauthorizedResponse) } yield response } ``` The admin route now uses the `UserInfo` object provided by the middleware: ```scala val users = Method.GET / "admin" / "users" -> Handler.fromZIO(ZIO.service[UserService]).flatMap { userService => Handler.fromZIO { ZIO.serviceWithZIO[UserInfo] { info: UserInfo => if (info.roles.contains("admin")) userService.getUsers.map { users => val userList = users.map(u => s"${u.username} (${u.email}) - Role: ${u.roles}").mkString("\n") Response.text(s"User List:\n$userList") } else ZIO.fail(Response.unauthorized(s"Access denied. User ${info.username} is not an admin.")) } } @@ jwtAuth(realm = "Admin Area") } ``` We verify authorization by checking if the `UserInfo` object's roles field contains the "admin" role. If it does, we grant access; otherwise, we respond with a `401 Unauthorized` status. The key advantage is that we no longer need to query the `UserService` for authorization decisions—all necessary information is contained within the JWT token. ## Writing a Client In this section, we will demonstrate how to write a client application that interacts with the protected API using JWT authentication. The client will perform two main steps: first, it sends a login request to obtain a JWT token, and then it uses the token to access protected routes. Let's start by implementing a ZIO HTTP client, followed by a JavaScript client. ### Writing a ZIO HTTP Client To implement the first step, we will create a ZIO HTTP client that sends a `POST` request to the `/login` endpoint with the user's credentials. Upon successful authentication, the server will respond with a JWT token: ```scala val url = "http://localhost:8080" val loginUrl = URL.decode(s"$url/login").toOption.get val loginRequest = ZClient .batched( Request .post(loginUrl, Body.fromURLEncodedForm( Form( FormField.simpleField("username", "john"), FormField.simpleField("password", "password123"), ), ), ) ) .flatMap(_.body.asString) ``` Making the login request will return the JWT token as a string. Next, we will use the received JWT token to access a protected route, such as `/profile/me`. We will include the token in the `Authorization` header of the request: ```scala val greetUrl = URL.decode(s"$url/profile/me").toOption.get loginRequest.flatMap { token => ZClient.batched(Request.get(greetUrl).addHeader(Header.Authorization.Bearer(token))) } ``` The client sends a `GET` request to the `/profile/me` endpoint, including the JWT token in the `Authorization` header. If the token is valid, the server will respond with the user's profile information. ### Writing a JavaScript Client It’s straightforward to create a JavaScript client—here are the steps: 1. Create a login form to collect user credentials. 2. Send a login request to obtain a JWT token. 3. Use the JWT token to access protected routes. #### Login Form To obtain a JWT token, we need to create a simple login form that takes the username and password from the user: ```html ``` The form includes two input fields for the username and password, and a button that triggers the `login` function when clicked. When the button is clicked, the `login` function sends a `POST` request to the `/login` endpoint with the provided credentials: ```javascript let currentToken = null; async function login() { const username = document.getElementById('username').value; const password = document.getElementById('password').value; try { const res = await fetch('/login', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ username, password }) }); const token = res.ok ? await res.text() : null; setTokenState(token); } catch(e) { setTokenState(null); } } ``` We store the received JWT token in the `currentToken` variable for later use. ### Sending Authenticated Requests Now that we have the JWT token, we can use it to access protected routes. For example, we can create a button that fetches the user's profile information from the `/profile/me` endpoint: ```html Endpoints:
Results will appear here...
``` The `fetchProfile` function sends a `GET` request to the `/profile/me` endpoint, including the JWT token in the `Authorization` header: ```javascript async function fetchProfile() { if (!currentToken) { document.getElementById('result').textContent = 'Please login first to get a JWT token'; return; } try { const res = await fetch('/profile/me', { headers: { 'Authorization': `Bearer ${currentToken}` } }); const text = await res.text(); document.getElementById('result').textContent = `${text}`; } catch(e) { document.getElementById('result').textContent = `Error: ${e.message}`; } } ``` We can do the same for all protected routes, such as the admin users endpoint. To improve usability, we can handle token expiration by implementing a refresh token mechanism on both the server and client. ## Source Code The complete source code for this JWT Bearer Token Authentication example is available in the ZIO HTTP repository. To clone the example: ```bash git clone --depth 1 --filter=blob:none --sparse https://github.com/zio/zio-http.git cd zio-http git sparse-checkout set zio-http-example-jwt-bearer-token-auth ``` ### Running the Server To run the authentication server: ```bash cd zio-http/zio-http-example-jwt-bearer-token-auth sbt "runMain example.auth.bearer.jwt.AuthenticationServer" ``` The server starts on `http://localhost:8080` with these test users: | Username | Password | Email | |----------|---------------|----------------------| | `john` | `password123` | john@example.com | | `jane` | `secret456` | jane@example.com | | `admin` | `admin123` | admin@company.com | ### ZIO HTTP Client Run the command-line client (ensure server is running): ```bash cd zio-http/zio-http-example-opaque-bearer-token-auth sbt "runMain example.auth.bearer.jwt.AuthenticationClient" ``` ### Web-Based Client To demonstrate the authentication flow in a web client, we've created a simple HTML page where users can log in, view their profile, and log out. First, start the `AuthenticationServer`, which provides the authentication API and serves the HTML client (`jwt-bearer-token-auth-client.html`) located in the resource folder: ```scala sbt "runMain example.auth.bearer.jwt.AuthenticationServer" ``` Then open [http://localhost:8080](http://localhost:8080) in your browser to interact with the system using predefined credentials. You can log in, view your profile, and log out, showcasing the full JWT bearer token authentication flow. The HTML file's source code can be found in the example project's resource folder. ## Demo We have deployed a live demo the server and the web client at: [https://jwt-bearer-token-auth-demo.ziohttp.com/](https://jwt-bearer-token-auth-demo.ziohttp.com/) The demo allows you to experience the authentication flow firsthand. You can log in using the predefined users, access their profiles, and log out to see how token revocation works in practice. All HTTP transactions can be inspected at the bottom of the page, so you can see the requests and responses in detail. ## Conclusion JSON Web Tokens have revolutionized API authentication by providing a stateless, scalable solution that aligns perfectly with modern architectural patterns. Throughout this guide, we've explored the journey from traditional session-based authentication to the token-based approach that dominates today's distributed systems. Here are some key takeaways: **JWTs excel in specific scenarios**: They're ideal for RESTful APIs, microservices architectures, cross-domain authentication, and temporary access tokens. Their self-contained nature eliminates database lookups and enables true horizontal scaling. When you need stateless authentication that works seamlessly across multiple services and domains, JWTs are often the perfect choice. **Security requires careful consideration**: Remember that JWTs are encoded, not encrypted. The payload is visible to anyone who intercepts the token, making HTTPS mandatory for production deployments. Never store sensitive information like passwords or credit card numbers in JWT payloads, and always validate tokens properly on the server side. **Choose your signing strategy wisely**: Symmetric signing offers simplicity and performance for single-service architectures, while asymmetric signing provides better security and flexibility for distributed systems. Your choice should align with your architecture's complexity and security requirements. **Token management matters**: The stateless nature of JWTs means they can't be revoked before expiration without additional infrastructure. Keep token lifetimes short, implement refresh token patterns for long-lived sessions, and consider maintaining a token blacklist for emergency revocations if your security requirements demand it. **Implementation patterns affect scalability**: The decision to include user roles and permissions in JWT claims versus fetching them from a database on each request has significant implications. Including them in the token reduces database load and improves performance but makes permission changes slower to propagate. Choose the pattern that best fits your application's needs. --- ## Securing Your APIs: Authentication with Opaque Bearer Tokens Bearer token authentication provides a stateless, secure mechanism for API access control by requiring clients to present tokens with each request. This guide demonstrates how to implement a robust opaque bearer token authentication system in ZIO HTTP, covering both server implementation and client integration. ## What is Bearer Token Authentication? Bearer token authentication is an HTTP authentication scheme that provides secure access to resources by requiring clients to present a token with each request. The term "bearer" implies that whoever possesses the token (the bearer) can access the associated resources, similar to how a physical ticket grants entry to an event. In this authentication method, the client includes a token in the `Authorization` header of HTTP requests using the format: `Authorization: Bearer `. Unlike traditional session-based authentication where the server maintains session state through cookies, bearer token authentication enables stateless communication between clients and servers. The token itself serves as the credential, eliminating the need for the server to look up session information in a centralized session store for every request—though as you'll see with opaque tokens, some server-side state management is still involved. ## Why Use Token-Based Authentication? Token-based authentication offers several compelling advantages over traditional session-based approaches: - **Stateless Architecture Support**: Tokens enable truly stateless server architectures (especially with self-contained tokens), where each request contains all the information needed for authentication. This eliminates server-side session storage requirements and simplifies horizontal scaling, as any server in a cluster can handle any request without sharing session state. - **Cross-Domain and CORS Friendly**: Unlike cookies, which are bound to specific domains and can create complications with Cross-Origin Resource Sharing (CORS), tokens can be easily sent to any domain. This makes them ideal for scenarios where your API serves multiple client applications hosted on different domains, or when building microservices that need to communicate across service boundaries. - **Mobile and IoT Ready**: Token-based authentication works seamlessly across different platforms and devices. Mobile applications, IoT devices, and desktop applications can all use the same authentication mechanism without dealing with cookie storage limitations or browser-specific behaviors. Tokens can be stored using platform-specific secure storage mechanisms. - **Fine-Grained Access Control**: Tokens can carry detailed authorization information, including user permissions, access scopes, and identity claims either embedded directly in the token or through database references. This allows for complex authorization strategies such as issuing tokens with minimal necessary permissions, creating temporary tokens for specific operations with elevated privileges, or generating scoped tokens that only grant access to particular resources. - **API Economy Integration**: Tokens are the foundation of modern API authentication standards like OAuth 2.0 and OpenID Connect. They enable secure third-party integrations, allowing your application to interact with external services or expose your own APIs to partners and developers. ## Understanding Opaque Tokens ### What are Opaque Tokens? Opaque tokens are authentication tokens that appear as random, meaningless strings to clients. The term "opaque" signifies that the token's content is not transparent or readable to the client—it's simply an identifier that references authentication information stored on the server. The token generation can be a cryptographically secure random string like this: ```scala def generateSecureToken: UIO[String] = ZIO.succeed { val random = new SecureRandom() val bytes = new Array[Byte](32) random.nextBytes(bytes) java.util.Base64.getUrlEncoder.withoutPadding.encodeToString(bytes) } ``` The resulting token might look like: `pC7SRyZ_WK5TbIml1coCTC4NwnE4nSHwEjlSkH__z_A`. When a client presents an opaque token, the server must look up the associated information in its storage system. This lookup reveals the token's validity, associated user, permissions, and other metadata. The client cannot decode, modify, or extract any information from the token itself—it's just a random string that has meaning only to the server that issued it. This opacity provides an important security property: even if an attacker obtains a token, they cannot learn anything about the user, permissions, or system internals by examining the token itself. The token reveals nothing about its purpose, scope, or associated identity without server-side lookup. ### Opaque Tokens vs. Self-contained Tokens (JWT) The fundamental distinction between opaque tokens and self-contained tokens like JSON Web Tokens (JWT) lies in where and how authentication information is stored and validated: **Information Storage**: - **Opaque Tokens**: All authentication data (user identity, permissions, expiration) is stored server-side. The token is just a reference key to this data. - **JWTs**: Contain encoded claims directly within the token structure. The token itself carries user information, expiration time, issuer details, and custom claims in a Base64-encoded JSON payload. **Validation Process**: - **Opaque Tokens**: Require a server-side lookup for every validation. The server must query its token storage to verify validity and retrieve associated information. - **JWTs**: Can be validated using cryptographic signatures without any database lookup. The server only needs the signing key to verify the token's authenticity and can trust the embedded claims. **Revocation Capabilities**: - **Opaque Tokens**: Can be immediately revoked by removing them from server storage. Once deleted, the token becomes invalid instantly across all services. - **JWTs**: Cannot be easily revoked before expiration since they're self-contained. Revocation requires maintaining a blocklist (defeating the stateless advantage) or keeping tokens short-lived with refresh token patterns. **Size and Transmission**: - **Opaque Tokens**: Typically compact (32-64 characters), resulting in smaller HTTP headers and reduced bandwidth usage. - **JWTs**: Can become quite large (hundreds of characters), especially with multiple claims, potentially causing issues with HTTP header size limits. These are some of the key differences between opaque tokens and self-contained tokens like JWTs. The choice between them depends on the specific requirements of your application, such as security needs, performance considerations, and architectural constraints (such as whether you work with a monolithic or microservices architecture). ### Authentication Flow Overview The bearer token authentication flow with opaque tokens follows a well-defined sequence of interactions between the client and server. Let's examine this flow as implemented in our ZIO HTTP example: 1. **Initial Authentication (Login)**: The client initiates the authentication process by sending credentials to the `/login` endpoint. The server validates that the password matches the one stored for the given username. Upon successful validation, the server generates a token, stores it with the username and expiration time, and returns the token to the client. 2. **Accessing Protected Resources**: When accessing protected routes, the client includes the token in the Authorization header. The server's authentication middleware intercepts the request, validates the token against its storage, and either allows the request to proceed with the user context or rejects it with a 401 Unauthorized response. 3. **Token Lifecycle Management**: The authentication flow includes token lifecycle management through logout (explicit revocation) and automatic cleanup of expired tokens. This ensures that users can invalidate their sessions immediately when they want to log out, and also that the token storage doesn't grow indefinitely. This is the simple flow of how opaque token authentication works. It can be extended with additional features like refresh tokens and scopes and permissions, but the core principles remain the same. The server issues tokens that clients use to authenticate requests, and the server maintains those tokens to grant or deny access to resources. ## Implementation Similar to previous guides, we will implement the authentication system using `HandlerAspect`/`Middleware` to intercept requests and authenticate users as they access protected resources. Before we dive into the implementation, let's outline the components we will need: a `TokenService` for managing opaque tokens and a `UserService` for handling user accounts. ### Token Service The `TokenService` forms the backbone of our authentication system, managing the entire lifecycle of opaque tokens from creation to revocation. Let's define an interface that outlines the essential operations for our token service: ```scala trait TokenService { def create(username: String): UIO[String] def validate(token: String): UIO[Option[String]] def cleanup(): UIO[Unit] def revoke(username: String): UIO[Unit] } ``` It consists of four key operations: creating, validating, revoking, and cleaning up tokens. The `create` method generates a new token for a given user with a specified lifetime, while `validate` checks if a token is valid and returns the associated username if it is. The `revoke` method invalidates all tokens for a specific user, and the `cleanup` method removes expired tokens. For simplicity, we will implement the `TokenService` using an in-memory store: ```scala case class TokenInfo(username: String, expiresAt: Instant) class InmemoryTokenService(tokenStorage: Ref[Map[String, TokenInfo]]) extends TokenService { private val TOKEN_LIFETIME = 300.seconds override def create(username: String): UIO[String] = for { token <- generateSecureToken now <- Clock.instant _ <- tokenStorage.update { tokens => tokens + (token -> TokenInfo( username = username, expiresAt = now.plusSeconds(TOKEN_LIFETIME.toSeconds), )) } } yield token override def validate(token: String): UIO[Option[String]] = tokenStorage.modify { tokens => tokens.get(token) match { case Some(tokenInfo) if tokenInfo.expiresAt.isAfter(Instant.now()) => (Some(tokenInfo.username), tokens) case Some(_) => // Token expired, remove it (None, tokens - token) case None => (None, tokens) } } override def cleanup(): UIO[Unit] = tokenStorage.update { _.filter { case (_, tokenInfo) => tokenInfo.expiresAt.isAfter(Instant.now()) } } override def revoke(username: String): UIO[Unit] = tokenStorage.update { _.filter { case (_, tokenInfo) => tokenInfo.username != username } } private def generateSecureToken: UIO[String] = ZIO.succeed { val random = new SecureRandom() val bytes = new Array[Byte](32) random.nextBytes(bytes) java.util.Base64.getUrlEncoder.withoutPadding.encodeToString(bytes) } } ``` The `InmemoryTokenService` implementation uses the ZIO `Ref` to manage the token storage, ensuring thread-safety and allowing multiple concurrent access. When validating tokens, the service automatically removes expired tokens during the lookup process. The service layer includes automatic cleanup of expired tokens to prevent unbounded memory growth: ```scala object TokenService { private val CLEANUP_INTERVAL = 60.seconds val inmemory: ZLayer[Any, Nothing, InmemoryTokenService] = ZLayer.scoped( for { service <- Ref.make(Map.empty[String, TokenInfo]).map(new InmemoryTokenService(_)) _ <- service.cleanup().repeat(Schedule.spaced(CLEANUP_INTERVAL)).forkScoped } yield service, ) } ``` ### User Service The next step is to define the `UserService`, which is the same as in the digest authentication guide, but we will include it here for completeness. The `UserService` manages user accounts and their associated data, such as usernames, passwords, and emails: ```scala case class User(username: String, password: Secret, email: String) sealed trait UserServiceError object UserServiceError { case class UserNotFound(username: String) extends UserServiceError case class UserAlreadyExists(username: String) extends UserServiceError } trait UserService { def getUser(username: String): IO[UserNotFound, User] def addUser(user: User): IO[UserAlreadyExists, Unit] def updateEmail(username: String, newEmail: String): IO[UserNotFound, Unit] } case class UserServiceLive(users: Ref[Map[String, User]]) extends UserService { def getUser(username: String): IO[UserNotFound, User] = users.get.flatMap { userMap => ZIO.fromOption(userMap.get(username)).orElseFail(UserNotFound(username)) } def addUser(user: User): IO[UserAlreadyExists, Unit] = users.get.flatMap { userMap => ZIO.when(userMap.contains(user.username)) { ZIO.fail(UserAlreadyExists(user.username)) } *> users.update(_.updated(user.username, user)) } def updateEmail(username: String, newEmail: String): IO[UserNotFound, Unit] = for { currentUsers <- users.get user <- ZIO.fromOption(currentUsers.get(username)).orElseFail(UserNotFound(username)) _ <- users.update(_.updated(username, user.copy(email = newEmail))) } yield () } ``` To initialize the `UserService`, we can use a simple in-memory store with a predefined set of users. This is useful for testing and demonstration purposes, but in a real application, you would typically connect to a database or another persistent storage solution. ```scala object UserService { private val initialUsers = Map( "john" -> User("john", Secret("password123"), "john@example.com"), "jane" -> User("jane", Secret("secret456"), "jane@example.com"), "admin" -> User("admin", Secret("admin123"), "admin@company.com"), ) val live: ZLayer[Any, Nothing, UserService] = ZLayer.fromZIO { Ref.make(initialUsers).map(UserServiceLive(_)) } } ``` We instantiate the service using a predefined set of users, which allows us to test the authentication flow without handling registration or user creation. :::note In production applications, we shouldn't store passwords in plain text. Instead, we should use a secure hashing algorithm like bcrypt or Argon2 to hash passwords before storing them. ::: ### Authentication Middleware The authentication middleware serves as the security gateway for protected resources, implementing a clean separation between authentication logic and business logic. It is responsible for intercepting incoming requests and authenticating users based on the provided bearer token. This middleware will use the `TokenService` to validate tokens and the `UserService` to retrieve user information associated with the token: ```scala object AuthHandlerAspect { def authenticate: HandlerAspect[TokenService with UserService, User] = HandlerAspect.interceptIncomingHandler(Handler.fromFunctionZIO[Request] { request => request.header(Header.Authorization) match { case Some(Header.Authorization.Bearer(token)) => ZIO.serviceWithZIO[TokenService](_.validate(token.stringValue)).flatMap { case Some(username) => ZIO .serviceWithZIO[UserService](_.getUser(username)) .map(user => (request, user)) .orElse( ZIO.fail( Response.unauthorized("User not found!"), ), ) case None => ZIO.fail(Response.unauthorized("Invalid or expired token!")) } case _ => ZIO.fail( Response.unauthorized.addHeaders(Headers(Header.WWWAuthenticate.Bearer(realm = "Access"))), ) } }) } ``` This middleware checks for the presence of the `Authorization` header in the incoming request. If the header is present and contains a valid bearer token, it retrieves the associated username from the `TokenService`. Then, it uses the `UserService` to fetch the user details. If successful, it allows the request to proceed with the user context; otherwise, it returns an unauthorized response. ### Server Routes All the components are in place, and now we can start defining the server routes. First, we will define a route for login, which will handle token generation, and then we will create a protected route that requires authentication to access user profile information. Finally, we'll implement logout functionality to revoke tokens. #### Login The login route is responsible for taking user credentials (username and password) and generating an opaque token upon successful authentication: ```scala val login = Method.POST / "login" -> handler { (request: Request) => val form = request.body.asURLEncodedForm.orElseFail(Response.badRequest) for { username <- form .map(_.get("username")) .flatMap(ff => ZIO.fromOption(ff).orElseFail(Response.badRequest("Missing username field!"))) .flatMap(ff => ZIO.fromOption(ff.stringValue).orElseFail(Response.badRequest("Missing username value!"))) password <- form .map(_.get("password")) .flatMap(ff => ZIO.fromOption(ff).orElseFail(Response.badRequest("Missing password field!"))) .flatMap(ff => ZIO.fromOption(ff.stringValue).orElseFail(Response.badRequest("Missing password value!"))) users <- ZIO.service[UserService] user <- users.getUser(username).orElseFail(Response.unauthorized(s"Username or password is incorrect.")) tokenService <- ZIO.service[TokenService] response <- if (user.password == Secret(password)) tokenService.create(username).map(Response.text) else ZIO.fail(Response.unauthorized("Username or password is incorrect.")) } yield response }, ``` This login route processes POST requests with URL-encoded username and password fields, extracts the form data, retrieves the user from `UserService`, and generates an authentication token via `TokenService`. If the credentials are valid, it returns the token in the response; otherwise, it responds with a 401 Unauthorized status. #### Protected Route: Profile Let's write the protected route `GET /profile/me`, which returns the profile of the user: ```scala val profile = Method.GET / "profile" / "me" -> handler { (_: Request) => ZIO.serviceWith[User](user => Response.text( s"This is your profile: \n Username: ${user.username} \n Email: ${user.email}", ), ) } @@ authenticate ``` This route is protected by the `authenticate` middleware we defined earlier. It retrieves the authenticated user from the request context and returns their profile information. The client should use the token issued by the server after login by including it in the `Authorization` header to access this protected route, e.g., API call: ```http GET /profile/me HTTP/1.1 Authorization: Bearer pC7SRyZ_WK5TbIml1coCTC4NwnE4nSHwEjlSkH__z_A ``` #### Logging out and Revoking Tokens One of the key benefits of opaque tokens is that they can be easily revoked by the server. To implement a logout route that revokes the user's token, we can define the following route: ```scala val logout = Method.POST / "logout" -> Handler.fromZIO(ZIO.service[TokenService]).flatMap { tokenService => handler { (request: Request) => request.header(Header.Authorization) match { case Some(Header.Authorization.Bearer(token)) => tokenService.validate(token.stringValue).flatMap { case Some(username) => tokenService.revoke(username).as(Response.text("Logged out successfully!")) case None => ZIO.fail(Response.unauthorized("Invalid or expired token!")) } case _ => ZIO.fail( Response.unauthorized.addHeaders(Headers(Header.WWWAuthenticate.Bearer(realm = "Access"))), ) } } @@ authenticate.as[Unit](()) } ``` As we don't require the returned user context for the logout operation, we convert the `HandlerAspect[TokenService & UserService, User]` to `HandlerAspect[TokenService & UserService, Unit]` using `as[Unit](())`. This allows us to focus solely on the token revocation logic without requiring user details from the context. ## Client The following ZIO HTTP client demonstrates how to interact with the authentication server we just built. It performs the login operation to obtain a token, then uses that token to access the protected profile route: ```scala object AuthenticationClient extends ZIOAppDefault { val url = "http://localhost:8080" val loginUrl = URL.decode(s"$url/login").toOption.get val profileUrl = URL.decode(s"$url/profile/me").toOption.get val program = for { client <- ZIO.service[Client] token <- client .batched( Request .post( loginUrl, Body.fromURLEncodedForm( Form( FormField.simpleField("username", "john"), FormField.simpleField("password", "password123"), ), ), ), ) .flatMap(_.body.asString) profileBody <- client .batched(Request.get(profileUrl).addHeader(Header.Authorization.Bearer(token))) .flatMap(_.body.asString) _ <- ZIO.debug(s"Protected route response: $profileBody") } yield () override val run = program.provide(Client.default) } ``` Using the same `token` we obtained, we can try to log out or revoke the token, so the client can't access the protected profile route anymore: ```scala val logoutUrl = URL.decode(s"$url/logout").toOption.get for { _ <- ZIO.debug("Logging out...") logoutBody <- client .batched(Request.post(logoutUrl, Body.empty).addHeader(Header.Authorization.Bearer(token))) .flatMap(_.body.asString) _ <- ZIO.debug(s"Logout response: $logoutBody") _ <- ZIO.debug("Trying to access protected route after logout...") body <- client .batched(Request.get(profileUrl).addHeader(Header.Authorization.Bearer(token))) .flatMap(_.body.asString) _ <- ZIO.debug(s"Protected route response after logout: $body") } yield () ``` After logging out, the client attempts to access the profile route again, which should fail with an unauthorized response since the token has been revoked. ## Source Code The complete source code for this Opaque Bearer Token Authentication example is available in the ZIO HTTP repository. To clone the example: ```bash git clone --depth 1 --filter=blob:none --sparse https://github.com/zio/zio-http.git cd zio-http git sparse-checkout set zio-http-example-opaque-bearer-token-auth ``` ### Running the Server To run the authentication server: ```bash cd zio-http/zio-http-example-opaque-bearer-token-auth sbt "runMain example.auth.bearer.opaque.AuthenticationServer" ``` The server starts on `http://localhost:8080` with these test users: | Username | Password | Email | |----------|---------------|----------------------| | `john` | `password123` | john@example.com | | `jane` | `secret456` | jane@example.com | | `admin` | `admin123` | admin@company.com | ### ZIO HTTP Client Run the command-line client (ensure server is running): ```bash cd zio-http/zio-http-example-opaque-bearer-token-auth sbt "runMain example.auth.bearer.opaque.AuthenticationClient" ``` Example output: ``` Protected route response: Welcome john! This is your profile: Username: john Email: john@example.com Logging out... Logout response: Logged out successfully! Trying to access protected route after logout... Protected route response after logout: Invalid or expired token! ``` ### Web-Based Client To demonstrate the authentication flow in a web client, we've created a simple HTML page where users can log in, view their profile, and log out. First, start the `AuthenticationServer`, which provides the authentication API and serves the HTML client (`opaque-bearer-token-auth-client.html`) located in the resource folder: ```scala sbt "zioHttpExample/runMain example.auth.bearer.opaque.AuthenticationServer" ``` Then open [http://localhost:8080](http://localhost:8080) in your browser to interact with the system using predefined credentials. You can log in, view your profile, and log out, showcasing the full opaque bearer token authentication flow. The HTML file's source code can be found in the example project's resource folder. ## Demo We have deployed a live demo of the server and the web client at: [https://opaque-bearer-token-auth-demo.ziohttp.com/](https://opaque-bearer-token-auth-demo.ziohttp.com/) The demo allows you to experience the authentication flow firsthand. You can log in using the predefined users, access their profiles, and log out to see how token revocation works in practice. All HTTP transactions can be inspected at the bottom of the page, so you can see the requests and responses in detail. ## Future Works In this guide, we have implemented a basic opaque bearer token authentication system using ZIO HTTP. In future iterations of this authentication system, we could consider implementing the following enhancements: 1. **Refresh Tokens**: Introduce refresh tokens to allow users to obtain new access tokens without re-authenticating, extending session lifetimes while maintaining security. 2. **Scopes and Permissions**: Implement fine-grained access control by associating scopes or permissions with tokens, allowing for more granular authorization decisions based on user roles or resource access levels. 3. **Rate Limiting**: Add rate limiting to protect against abuse and ensure fair usage of the API, preventing excessive requests from a single user or IP address. 4. **Password Hashing**: Use secure password hashing algorithms (e.g., bcrypt, Argon2) to store user passwords securely, enhancing security against password leaks. ## Conclusion Opaque bearer token authentication provides a robust and flexible approach for securing APIs, offering clear advantages in scenarios where fine-grained control, immediate revocation, and server-managed session data are priorities. By storing all authentication details server-side, opaque tokens mitigate the risk of exposing sensitive information within the token itself and simplify revocation processes compared to self-contained tokens like JWTs. The implementation outlined here demonstrates how to integrate opaque tokens into a ZIO HTTP application, from token generation and validation to writing authentication middleware. --- ## Securing Your APIs: Authentication with a Third-party OAuth Provider In this guide, we walk through implementing OAuth 2.0 authentication using GitHub as an identity provider, based on the provided Scala and ZIO HTTP implementation. We'll cover the complete authorization code flow, from initial setup to handling refresh tokens. ## Understanding OAuth 2.0 OAuth 2.0 is an authorization framework that enables applications to obtain limited access to user accounts on an HTTP service. Instead of sharing passwords, users can authorize applications to access their information stored with another service. Traditional authentication requires users to share their passwords with third-party applications, which poses several security risks. OAuth solves this by: - Never exposing user credentials to the client application - Allowing users to revoke access at any time - Enabling granular permissions (scopes) - Supporting multiple client types (web, mobile, desktop) While OAuth is an authorization framework, we can use it for authentication by leveraging the identity information provided by the OAuth provider. In this article, we focus on using OAuth for authentication. ## The Authorization Code Flow The authorization code flow is one of the most secure OAuth flows for server-side applications. In this guide, we use GitHub as the OAuth provider. Here's how the authorization flow works: ``` ┌─────────────┐ ┌─────────────┐ │ User │ │ │ │ │ │ GitHub │ │ (Browser) │ │ │ └──────┬──────┘ └──────┬──────┘ │ │ │ 1. Click "Login with GitHub" │ │────────────────────────────────────────────────>│ │ │ │ 2. Redirect to GitHub OAuth │ │<────────────────────────────────────────────────│ │ │ │ 3. User authorizes app │ │────────────────────────────────────────────────>│ │ │ │ 4. Redirect with authorization code │ │<────────────────────────────────────────────────│ │ │ ┌──────▼──────┐ │ │ │ 5. Exchange code for tokens │ │ Your App │─────────────────────────────────────────>│ │ Server │ │ │ │ 6. Return access & refresh tokens │ │ │<─────────────────────────────────────────│ └─────────────┘ ``` As you can see, the flow involves several steps: 1. The user initiates the login process by clicking a button. 2. The browser redirects the user to [GitHub's authorization endpoint](https://github.com/login/oauth/authorize) and passes the `client_id`, `redirect_uri`, `scope`, and a random `state` parameter for CSRF protection. 3. The user logs in to GitHub, and GitHub displays an authorization prompt with the requested scopes. For example, if the app requests the `user:email` scope, GitHub will show that the app wants access to the user's email addresses. 4. Upon user approval, GitHub redirects back to the application's `redirect_uri` with an authorization `code` and the original `state`. 5. The application server verifies the `state` to prevent CSRF attacks, then exchanges the authorization `code` for an access token and a refresh token by making a POST request to [GitHub's token endpoint](https://github.com/login/oauth/access_token). 6. The server receives the tokens and can now use the access token to make authenticated requests to GitHub's API on behalf of the user. For example, we can fetch the [user's profile](https://api.github.com/user) information and email addresses, which can be used to create a session in our application. In this guide, we issue our own JWT tokens for session management. ## Creating a GitHub OAuth App To get started, we need to create a GitHub OAuth App to obtain the `Client ID` and `Client Secret`. Follow these steps: 1. Navigate to GitHub Developer Settings at https://github.com/settings/developers 2. In the developer settings page, locate and click the "New OAuth App" button. 3. Complete the application registration form with these details: - Application name: Choose a descriptive name for your application - Set the Homepage URL to `http://localhost:8080` (as we are running the server locally) - Configure the Authorization callback URL as `http://localhost:8080/auth/github/callback` 4. After registration, securely store the provided Client ID and Client Secret credentials. ## Configuring Environment Variables After creating the OAuth app, we need to configure our application with the obtained credentials. Add the following environment variables to your system: ```bash export GH_CLIENT_ID="your_client_id_here" export GH_CLIENT_SECRET="your_client_secret_here" export BASE_URL="http://localhost:8080" ``` ## Server Implementation In this section, we first implement the OAuth authentication service that handles the OAuth flow, and then we implement authentication middleware to protect specific routes. ### OAuth Authentication Service The `GithubAuthService` orchestrates the entire OAuth flow: ```scala class GithubAuthService private ( private val redirectUris: Ref[Map[String, URI]], // state -> redirectUri private val users: Ref[Map[String, GitHubUser]], // userId -> GitHubUser private val clientID: String, private val clientSecret: Secret, private val baseUrl: String, ) { // Key methods for handling OAuth flow } ``` It maintains two in-memory states: 1. **Redirect URIs**: Maps OAuth state parameters to redirect URIs. 2. **User Information**: Stores GitHub user information indexed by user ID. You may ask: what is the purpose of maintaining state parameters and their respective URIs? The `state` parameter provides CSRF protection. When using OAuth 2.0, a client app redirects the user to the authorization server for login/consent. After login, the authorization server redirects the user back to the client with an authorization code. Without the state parameter, an attacker could trick the user into visiting a malicious link that points back to your app's redirect endpoint but with an authorization code that belongs to the attacker's account. If your app accepts that code blindly (without checking the state parameter), it might link the attacker's account to the victim's session. As a result, the victim is logged into the attacker's account. From that point on, the attacker can log into their account and see anything the victim did while "logged in as the attacker." To prevent this, the client app generates a cryptographically strong random `state` value when initiating the OAuth flow and stores it (e.g., in memory or a database) along with the intended redirect URI. The client passes this `state` to the authorization server. After the user logs in and authorizes, the authorization server redirects back to the client with both the authorization code and the original `state`. When handling the callback, the app verifies that the returned `state` matches the stored value. If they don't match, the request is rejected. The attacker cannot guess the valid `state` bound to the victim's session, so their forged callback is rejected. Therefore, only OAuth responses that your app actually initiated can complete successfully, neutralizing login CSRF. To keep the implementation simple, we've added another functionality to this service - storing user information. After exchanging the authorization code for an access token, we fetch the user's profile from GitHub and store it in memory. This allows us to associate our own JWT tokens with GitHub user data. Let's start by implementing these two core functionalities: ```scala class GithubAuthService private ( private val redirectUris: Ref[Map[String, URI]], // state -> redirectUri private val users: Ref[Map[String, GitHubUser]], // userId -> GitHubUser private val clientID: String, private val clientSecret: Secret, private val baseUrl: String, ) { private val REDIRECT_URI = s"$baseUrl/auth/github/callback" private val GITHUB_AUTHORIZE_URL = "https://github.com/login/oauth/authorize" val authorize = Method.GET / "auth" / "github" -> handler { (request: Request) => for { state <- generateRandomString() redirectUri <- ZIO.fromOption(request.url.queryParams.queryParams("redirect_uri").headOption).orElseFail( Response.badRequest("Missing redirect_uri parameter") ) _ <- redirectUris.update(_.updated(state, URI.create(redirectUri))) githubAuthUrl <- ZIO .fromEither( URL.decode( s"$GITHUB_AUTHORIZE_URL" + s"?client_id=$clientID" + s"&redirect_uri=$REDIRECT_URI" + s"&scope=user:email" + s"&state=$state", ), ) } yield Response.status(Status.Found).addHeader(Header.Location(githubAuthUrl)) } } ``` The `GET /auth/github` route redirects the user to GitHub's authorization URL, passing the `client_id`, `redirect_uri`, `scope`, and finally the `state` parameter. We generate the `state` using the following cryptographically secure function: ```scala def generateRandomString(): ZIO[Any, Nothing, String] = ZIO.succeed { val bytes = new Array[Byte](32) // 256 bits java.security.SecureRandom.getInstanceStrong.nextBytes(bytes) bytes.map("%02x".format(_)).mkString } ``` The `redirect_uri` is another route that handles GitHub's callback. After the user logs in and authorizes, GitHub's authorization server redirects the user to the following callback URL, passing the authentication `code`, `state`, and `error` parameters: ```scala class GithubAuthService private ( private val redirectUris: Ref[Map[String, URI]], // state -> redirectUri private val users: Ref[Map[String, GitHubUser]], // userId -> GitHubUser private val clientID: String, private val clientSecret: Secret, private val baseUrl: String, ) { private val REDIRECT_URI = s"$baseUrl/auth/github/callback" private val GITHUB_AUTHORIZE_URL = "https://github.com/login/oauth/authorize" private val GITHUB_USER_API = "https://api.github.com/user" val callback = Method.GET / "auth" / "github" / "callback" -> handler { (request: Request) => val queryParams = request.url.queryParams // Authorization code returned by GitHub after successful user authentication // This temporary code is exchanged for an access token val code = queryParams.queryParams("code").headOption // The same random string your server generated and sent to GitHub initially // prevents malicious sites from initiating fake OAuth flows // The server must verify this matches the state it originally sent val state = queryParams.queryParams("state").headOption // Error code indicating why the OAuth flow failed val error = queryParams.queryParams("error").headOption error match { case Some(err) => ZIO.succeed(Response.unauthorized(s"OAuth error: $err")) case None => (code, state) match { case (Some(authCode), Some(stateParam)) => for { uri <- redirectUris.get.map(_.get(stateParam)) redirectUri <- ZIO.fromOption(uri) // Exchange code for access token githubTokens <- exchangeCodeForToken(authCode) // Fetch user info from GitHub githubUser <- fetchGitHubUser(githubTokens.access_token) // Generate our own JWT tokens token <- ZIO.serviceWithZIO[JwtTokenService]( _.issueTokens(githubUser.id.toString, githubUser.email.getOrElse(""), Set("user")), ) // Clean up state, store user info, and store refresh token _ <- redirectUris.update(_.removed(stateParam)) _ <- users.update(_.updated(githubUser.id.toString, githubUser)) // Redirect back to the client with tokens redirectUrl <- ZIO .fromEither( URL.decode( s"$redirectUri?access_token=${token.accessToken}" + s"&refresh_token=${token.refreshToken}" + s"&token_type=${"Bearer"}" + s"&expires_in=${token.expiresIn}", ), ) } yield Response.status(Status.Found).addHeader(Header.Location(redirectUrl)) case _ => ZIO.succeed(Response.badRequest("Missing code or state parameter")) } } } } ``` After receiving the authorization code, we use it to get the access token from GitHub's access token endpoint using the `exchangeCodeForToken` function: ```scala private val GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token" def exchangeCodeForToken(code: String): ZIO[Client, Throwable, GitHubToken] = for { response <- ZClient.batched( Request .post( path = GITHUB_TOKEN_URL, body = Body.from( Map( "client_id" -> clientID, "client_secret" -> clientSecret.stringValue, "code" -> code, "redirect_uri" -> REDIRECT_URI, ), ), ) .addHeader(Header.ContentType(MediaType.application.json)) .addHeader(Header.Accept(MediaType.application.json)), ) _ <- ZIO .fail(new Exception(s"GitHub token exchange failed: ${response.status}")) .when(!response.status.isSuccess) body <- response.body.asString token <- ZIO .fromEither(body.fromJson[GitHubToken]) .mapError(error => new Exception(s"Failed to parse GitHub token response: $error")) } yield token ``` Now, using this access token, we can access GitHub's protected user API to fetch the user's profile information: ```scala val GITHUB_USER_API = "https://api.github.com/user" def fetchGitHubUser(accessToken: String): ZIO[Client, Throwable, GitHubUser] = for { response <- ZClient.batched( Request .get(GITHUB_USER_API) .addHeader(Header.Authorization.Bearer(accessToken)) .addHeader(Header.Accept(MediaType.application.json)), ) _ <- ZIO .fail(new Exception(s"GitHub user API failed: ${response.status}")) .when(!response.status.isSuccess) body <- response.body.asString user <- ZIO .fromEither(body.fromJson[GitHubUser]) .mapError(error => new Exception(s"Failed to parse GitHub user response: $error")) } yield user ``` With the user's profile in hand, we can issue our own JWT and refresh tokens and return them to the user. We use the same `JwtTokenService` that we implemented in the previous [guide](authentication-with-jwt-bearer-and-refresh-tokens.md). One step remains. We need to pass the generated tokens (JWT token and refresh token) back to the client. Since this is a server-side application, we cannot return the tokens in the response body. Instead, we redirect the user back to the original `redirect_uri` with the tokens as query parameters: ```scala val response = ZIO.fromEither( URL.decode( s"$redirectUri?access_token=${token.accessToken}" + s"&refresh_token=${token.refreshToken}" + s"&token_type=${"Bearer"}" + s"&expires_in=${token.expiresIn}", ), ) .map{ redirectUrl => Response.status(Status.Found).addHeader(Header.Location(redirectUrl)) } ``` In the next step, the client extracts the tokens from the URL and stores them securely; then the client can use them to make authenticated requests to the server. We will discuss the client-side implementation later in this guide. Let's move on to the middleware implementation, which enables us to protect certain routes. ### Authentication Middleware The implementation of authentication middleware is the same as we discussed in the previous guide: ```scala object AuthMiddleware { def jwtAuth(realm: String): HandlerAspect[JwtTokenService, UserInfo] = HandlerAspect.interceptIncomingHandler { handler { (request: Request) => request.header(Header.Authorization) match { case Some(Header.Authorization.Bearer(token)) => ZIO .serviceWithZIO[JwtTokenService](_.verify(token.value.asString)) .map(userInfo => (request, userInfo)) .orElseFail( Response .unauthorized("Invalid or expired token.") .addHeaders(Headers(Header.WWWAuthenticate.Bearer(realm))), ) case _ => ZIO.fail(Response.unauthorized.addHeaders(Headers(Header.WWWAuthenticate.Bearer(realm)))) } } } } ``` It extracts the Bearer token from the `Authorization` header, verifies it using the `JwtTokenService`, and injects the user information into the request context if the token is valid. If the token is missing or invalid, it responds with a 401 Unauthorized status. ### Protected Routes Using the authentication middleware, we can protect certain routes. For example, let's protect the `/profile/me` endpoint, which returns the user's profile: ```scala val profile = Method.GET / "profile" / "me" -> handler { (_: Request) => ZIO.service[UserInfo].flatMap { userInfo => // Fetch user profile from in-memory store users.get.map { _.get(userInfo.username) match { case Some(user) => Response(body = Body.from(user)) case None => Response( status = Status.NotFound, body = Body.from( Map( "userId" -> userInfo.username, "message" -> "User profile not found", ), ), ) } } } } @@ AuthMiddleware.jwtAuth(realm = "User Profile") ``` In this example, the `/profile/me` endpoint is protected by the `jwtAuth` middleware. Only requests with a valid JWT token will be able to access this endpoint. After successful authentication, the user info is injected into the handler, allowing us to fetch the user's profile from the in-memory store. ## Client Implementation In this section, we implement the client-side logic to interact with the OAuth-protected server. We will demonstrate both a web client using JavaScript and a Scala client using ZIO HTTP. ### Web Client The web client is a simple HTML page with a "Sign in with GitHub" button. When the user clicks the button, they are redirected to the server's GitHub authorization endpoint. After successful authentication, the user is redirected back to the client with tokens in the URL. The authentication flow is initiated by a button click: ```html ``` When the user clicks the "Sign in with GitHub" button, the following JavaScript code redirects the user to the server's GitHub authorization endpoint: ```javascript const loginBtn = document.getElementById('loginBtn'); loginBtn.addEventListener('click', startAuth); function startAuth() { const redirectUri = encodeURIComponent(window.location.origin); const authUrl = `/auth/github?redirect_uri=${redirectUri}`; showLoading(true); showStatus('Redirecting to GitHub...', 'loading'); // Redirect to GitHub OAuth window.location.href = authUrl; } ``` After the user authorizes the app on GitHub, they are redirected back to the client with tokens in the URL. To extract the tokens, we check the URL parameters on each page load by adding an event listener for `DOMContentLoaded`: ```javascript let currentTokens = null; // Check for tokens in URL on page load window.addEventListener('DOMContentLoaded', () => { const urlParams = new URLSearchParams(window.location.search); const accessToken = urlParams.get('access_token'); const refreshToken = urlParams.get('refresh_token'); if (accessToken && refreshToken) { currentTokens = { accessToken: accessToken, refreshToken: refreshToken, tokenType: urlParams.get('token_type'), expiresIn: parseInt(urlParams.get('expires_in')) }; // Clean URL window.history.replaceState({}, document.title, window.location.pathname); fetchUserProfile(); } }); ``` After extracting the tokens from the URL, we must clean up the URL to remove sensitive data. Having sensitive tokens in the URL is a security risk because URLs can be logged in browser history or can be shared or bookmarked accidentally. We use the `window.history.replaceState()` method to modify the current history entry, replacing the URL with a clean version. This is a security best practice to prevent token exposure while maintaining the user's current session in memory. Now, we are ready to call the server to fetch the user's profile with the retrieved access token: ```javascript async function fetchUserProfile() { if (!currentTokens) return; try { const response = await fetch('/profile/me', { headers: { 'Authorization': `${currentTokens.tokenType} ${currentTokens.accessToken}` } }); if (response.ok) { const user = await response.json(); displayUserInfo(user); } else { throw new Error(`Failed to fetch profile: ${response.status}`); } } catch (error) { showStatus(`Error fetching profile: ${error.message}`, 'error'); } finally { showLoading(false); } } ``` ### ZIO HTTP Client To write a client application in Scala that interacts with the OAuth-protected server, we can use ZIO HTTP's client capabilities. Below is a simple interface for an OAuth client that handles login, logout, and making authenticated requests: ```scala trait OAuthClient { def makeAuthenticatedRequest(request: Request): IO[Throwable, Response] def login: IO[Throwable, Unit] def logout: IO[Throwable, Unit] } ``` Let's start by writing the `login` method: ```scala case class GithubOAuthClient( client: Client, tokenStore: Ref[Option[Token]], ) extends OAuthClient { private val serverUrl = "http://localhost:8080" private val callbackPort = 3000 private val callbackUrl = s"http://localhost:$callbackPort" private val refreshUrl = URL.decode(s"$serverUrl/refresh").toOption.get private val logoutUrl = URL.decode(s"$serverUrl/logout").toOption.get override def login: IO[Throwable, Unit] = for { tokenPromise <- Promise.make[Throwable, Token] // Start callback server serverFiber <- startCallbackServer(tokenPromise).fork // Build OAuth URL oauthUrl = s"$serverUrl/auth/github?redirect_uri=$callbackUrl" // Open browser for OAuth flow _ <- Console.printLine("Starting OAuth flow...") _ <- Console.printLine(s"Opening browser to: $oauthUrl") _ <- openBrowser(oauthUrl) // Wait for callback _ <- Console.printLine("Waiting for OAuth callback...") tokens <- tokenPromise.await.timeoutFail(new Exception("OAuth flow timed out"))(5.minutes) _ <- tokenStore.set(Some(tokens)) // Stop callback server _ <- serverFiber.interrupt } yield () override def makeAuthenticatedRequest(request: Request): IO[Throwable, Response] = ??? override def logout: IO[Throwable, Unit] = ??? } ``` The `startCallbackServer` function starts a temporary HTTP server to listen for the OAuth callback: ```scala private def startCallbackServer(tokenPromise: Promise[Throwable, Token]): ZIO[Any, Throwable, Server] = { val callbackRoutes = Routes( Method.GET / Root -> handler { (request: Request) => val queryParams = request.url.queryParams val accessToken = queryParams.queryParams("access_token").headOption val refreshToken = queryParams.queryParams("refresh_token").headOption val tokenType = queryParams.queryParams("token_type").headOption val expiresIn = queryParams.queryParams("expires_in").headOption.flatMap(_.toLongOption) (accessToken, refreshToken, tokenType, expiresIn) match { case (Some(at), Some(rt), Some(tt), Some(exp)) => val tokens = Token(at, rt, tt, exp) tokenPromise .succeed(tokens) .as( Response.html( Html.raw( """ | | |Authentication Successful | | Authentication Successful! | You have successfully authenticated with GitHub. | You can now close this window and return to the application. | | | """.stripMargin, ), ), ) case _ => val error = queryParams.queryParams("error").headOption.getOrElse("Unknown error") val errorDescription = queryParams.queryParams("error_description").headOption.getOrElse("") tokenPromise .fail(new Exception(s"OAuth error: $error - $errorDescription")) .as( Response.html( Html.raw( s""" | | |Authentication Failed | | Authentication Failed | Error: $error | $errorDescription | Please try again. | | """.stripMargin, ), ), ) } }, ) Server.serve(callbackRoutes).provide(Server.defaultWithPort(callbackPort)) } ``` It takes a `Promise` parameter that will be completed with the OAuth tokens when they arrive. It creates an HTTP route that listens for GET requests containing either successful authentication tokens (access token, refresh token, token type, and expiration) or error information in the query parameters. On successful authentication, it fulfills the promise with the token information and returns an HTML success page that automatically closes after 5 seconds. On failure, it fails the promise with the error details and returns an HTML error page. After the client receives the tokens using the `performOAuthFlow`, it can store them (`tokenStore.set(Some(tokens))`) and use them to perform authenticated requests. Now, we are ready to implement the `makeAuthenticatedRequest`: ```scala case class GithubOAuthClient( client: Client, tokenStore: Ref[Option[Token]], ) extends OAuthClient { override def login: IO[Throwable, Unit] = ??? override def logout: IO[Throwable, Unit] = ??? override def makeAuthenticatedRequest(request: Request): IO[Throwable, Response] = { def attemptRequest(accessToken: String): ZIO[Any, Throwable, Response] = client.batched(request.addHeader(Header.Authorization.Bearer(accessToken))) def refreshAndRetry(currentTokenStore: Token): ZIO[Any, Throwable, Response] = for { newTokens <- refreshTokens(currentTokenStore.refreshToken) _ <- tokenStore.set(Some(newTokens)) response <- attemptRequest(newTokens.accessToken) } yield response for { tokenStoreValue <- tokenStore.get response <- tokenStoreValue match { case Some(tokens) => attemptRequest(tokens.accessToken).flatMap { response => if (response.status == Status.Unauthorized) refreshAndRetry(tokens) else ZIO.succeed(response) } case None => login *> makeAuthenticatedRequest(request) } } yield response } } ``` When making a request, the `makeAuthenticatedRequest` first checks for stored tokens and attaches the access token as a Bearer token in the Authorization header. If the request returns an Unauthorized status (401), indicating an expired access token, it automatically attempts to refresh the token using the stored refresh token and retries the original request with the new access token. If no tokens are available, it initiates the login flow before retrying the request. This implementation provides a seamless authentication experience by handling token expiration and renewal transparently to the calling code. The final step is to implement the `logout` method. The implementation is straightforward - we call the `/logout` endpoint and remove the stored tokens: ```scala case class GithubOAuthClient( client: Client, tokenStore: Ref[Option[Token]], ) extends OAuthClient { private val serverUrl = "http://localhost:8080" private val logoutUrl = URL.decode(s"$serverUrl/logout").toOption.get override def makeAuthenticatedRequest(request: Request): IO[Throwable, Response] = ??? override def login: IO[Throwable, Unit] = ??? override def logout: IO[Throwable, Unit] = tokenStore.get.map(_.map(_.refreshToken)).flatMap { case Some(refreshToken) => val formData = Form(FormField.simpleField("refreshToken", refreshToken)) for { response <- client .batched(Request.post(logoutUrl, Body.fromURLEncodedForm(formData))) .orDie _ <- ZIO.when(!response.status.isSuccess) { ZIO.fail(new Exception(s"Logout failed: ${response.status}")) } _ <- tokenStore.set(None) } yield () case None => ZIO.unit } } ``` ## Source Code The complete source code for this OAuth 2.0 Authentication with GitHub example is available in the ZIO HTTP repository. To clone the example: ```bash git clone --depth 1 --filter=blob:none --sparse https://github.com/zio/zio-http.git cd zio-http git sparse-checkout set zio-http-example-oauth-bearer-token-auth ``` ### Setting Up Environment Variables First, create a GitHub OAuth App following the instructions in the "Creating a GitHub OAuth App" section. Then, set the required environment variables: ```bash # Set environment variables export GH_CLIENT_ID="your_client_id" export GH_CLIENT_SECRET="your_client_secret" export BASE_URL="http://localhost:8080" ``` ### Running the Server To run the authentication server: ```bash cd zio-http/zio-http-example-oauth-bearer-token-auth sbt "runMain example.auth.bearer.oauth.AuthenticationServer" ``` The server starts on `http://localhost:8080` and handles the complete GitHub OAuth authentication flow. ### ZIO HTTP Client Run the command-line client in a separate terminal (ensure the server is running): ```bash cd zio-http/zio-http-example-oauth-bearer-token-auth sbt "runMain example.auth.bearer.oauth.AuthenticationClient" ``` The client application will initiate the OAuth flow by starting a local callback server on port 3000 and opening your default browser for GitHub authentication. Once you authorize the application, the client captures the callback containing the tokens and can proceed to make authenticated requests to the protected endpoints. ### Web-Based Client The `AuthenticationServer` automatically serves the HTML client (`oauth-bearer-token-auth-client.html`) located in the resource folder. After starting the server, open [http://localhost:8080](http://localhost:8080) in your browser and click the "Sign in with GitHub" button. You'll be redirected to GitHub where you can authorize the application. After authorization, you'll be redirected back to confirm successful authentication. Finally, test the protected endpoints to verify everything is working correctly. The HTML file's source code can be found in the example project's resource folder. ## Demo We have deployed a live demo of the server and web client at: [https://oauth-bearer-token-auth-demo.ziohttp.com/](https://oauth-bearer-token-auth-demo.ziohttp.com/) The demo allows you to experience the complete OAuth 2.0 authentication flow with GitHub. You can sign in with your GitHub account, view your profile information, and see how the token exchange process works. HTTP transactions can be inspected to understand the OAuth flow in detail. ## Conclusion In this guide, we demonstrated an implementation of OAuth 2.0 authentication with GitHub using ZIO HTTP. The solution provides a secure and robust authentication system by implementing state management for CSRF protection, proper token exchange and storage mechanisms, and automatic token refresh capabilities. The combination of OAuth 2.0 for third-party authentication and JWT for session management offers several advantages. OAuth enables secure delegation of user authentication to GitHub, eliminating the need for password management, while JWT provides a stateless session management solution that scales well. Together, they create a secure foundation for building modern web applications that require user authentication. --- ## Securing Your APIs: Basic Authentication Basic Authentication is one of the simplest HTTP authentication schemes, widely used for securing web applications and APIs. In this guide, we'll explore how to implement Basic Authentication using ZIO HTTP, a powerful and type-safe HTTP library for Scala. ## Understanding Basic Authentication Basic Authentication is an HTTP authentication scheme that transmits credentials as username/password pairs, encoded in Base64. When a client makes a request, it includes an `Authorization` header with the format: ``` Authorization: Basic ``` The credentials are encoded as `username:password` and then Base64 encoded. For example, if the username is `john` and the password is `secret123`, we need to encode the `john:secret123` string to Base64: ```bash $ echo -n "john:secret123" | base64 am9objpzZWNyZXQxMjM= ``` So the header would be: ``` Authorization: Basic am9objpzZWNyZXQxMjM= ``` When the server receives a request with this header, it decodes the credentials and checks them against a user database or service. If the credentials are valid, the server allows access to the requested resource; otherwise, it responds with a `401 Unauthorized` status. ## Setting Up Dependencies First, add the necessary dependencies to our `build.sbt`: ```scala libraryDependencies ++= Seq( "dev.zio" %% "zio" % "2.1.26", "dev.zio" %% "zio-http" % "3.8.1", ) ``` ## Implementing Basic Authentication ### 1. Creating the Authentication Service Assume we have a simple in-memory user store that holds usernames, passwords, and roles: ```scala case class User(username: String, password: Secret, email: String, role: String) // Sample user database val users = Map( "john" -> User("john", Secret("secret123"), "john@example.com", "user"), "jane" -> User("jane", Secret("password456"), "jane@example.com", "user"), "admin" -> User("admin", Secret("admin123"), "admin@example.com", "admin"), ) ``` :::note In a real application, we would typically use a database or an external service for user management. Also, in production environments, we should never store passwords in plain text, as we will discuss later in the security best practices section. ::: ### 2. Creating Basic Authentication Middleware Next, we'll create middleware that handles Basic Authentication: ```scala val basicAuthWithUserContext: HandlerAspect[Any, User] = HandlerAspect.interceptIncomingHandler(Handler.fromFunctionZIO[Request] { request => request.header(Header.Authorization) match { case Some(Header.Authorization.Basic(username, password)) => users.get(username) match { case Some(user) if user.password == password => ZIO.succeed((request, user)) case _ => ZIO.fail( Response .unauthorized("Invalid username or password") .addHeaders(Headers(Header.WWWAuthenticate.Basic(realm = Some("Protected API")))), ) } case _ => ZIO.fail( Response .unauthorized("Authentication required") .addHeaders(Headers(Header.WWWAuthenticate.Basic(realm = Some("Protected API")))), ) } }) ``` This middleware checks for the `Authorization` header, decodes the credentials, and verifies them against our in-memory user store. If the credentials are valid, it allows the request to proceed by attaching the user data to the request context. If the credentials are invalid or missing, it responds with a `401 Unauthorized` status and a `WWW-Authenticate` header prompting for Basic Authentication. ### 3. Creating Protected Routes Now that the authentication middleware is ready, we can apply it to any routes that we want to protect: ```scala def routes: Routes[Any, Response] = Routes( // Public route - no authentication required Method.GET / "public" -> handler { (_: Request) => Response.text("This is a public endpoint accessible to everyone") }, // Route that uses the full User object Method.GET / "profile" / "me" -> handler { (_: Request) => ZIO.serviceWith[User](user => Response.text(s"Welcome ${user.username}!\nEmail: ${user.email}\nRole: ${user.role}"), ) } @@ basicAuthWithUserContext, ) @@ Middleware.debug ``` In this example, we have a `/public` route that anyone can access, and a protected route `/profile/me` that requires authentication. The `basicAuthWithUserContext` middleware is applied to the protected route, allowing us to access the authenticated user information within the handler. Note that the `basicAuthWithUserContext` middleware is applied to the route. This allows us to access the authenticated user information in the handler through the ZIO environment. We used `ZIO.serviceWith[User]` to access the user context, which was set by the middleware. This enables us to personalize responses based on the user's data. ### 4. Putting It All Together Here's how we combine everything into a complete application: ```scala object AuthenticationServer extends ZIOAppDefault { val basicAuthWithUserContext: HandlerAspect[Any, User] = HandlerAspect.interceptIncomingHandler(Handler.fromFunctionZIO[Request] { request => request.header(Header.Authorization) match { case Some(Header.Authorization.Basic(username, password)) => users.get(username) match { case Some(user) if user.password == password => ZIO.succeed((request, user)) case _ => ZIO.fail( Response .unauthorized("Invalid username or password") .addHeaders(Headers(Header.WWWAuthenticate.Basic(realm = Some("Protected API")))), ) } case _ => ZIO.fail( Response .unauthorized("Authentication required") .addHeaders(Headers(Header.WWWAuthenticate.Basic(realm = Some("Protected API")))), ) } }) def routes: Routes[Any, Response] = Routes( // Public route - no authentication required Method.GET / "public" -> handler { (_: Request) => Response.text("This is a public endpoint accessible to everyone") }, // Route that uses the full User object Method.GET / "profile" / "me" -> handler { (_: Request) => ZIO.serviceWith[User](user => Response.text(s"Welcome ${user.username}!\nEmail: ${user.email}\nRole: ${user.role}"), ) } @@ basicAuthWithUserContext, ) @@ Middleware.debug override val run = Server.serve(routes).provide(Server.default) } ``` This will serve the application on the default port (8080) and provide both public and protected routes. Now it is time to test the Basic Authentication by accessing the `/profile/me` endpoint with valid credentials. ## Testing the Protected Route To test the Basic Authentication, we can use tools like `curl`, Postman, or any HTTP client library. Here's how to do it with `curl`: ```bash $ curl -v -H "Authorization: Basic am9objpzZWNyZXQxMjM=" -X GET http://127.0.0.1:8080/profile/me > GET /profile/me HTTP/1.1 > Host: 127.0.0.1:8080 > User-Agent: curl/8.4.0 > Accept: */* > Authorization: Basic am9objpzZWNyZXQxMjM= > < HTTP/1.1 200 Ok < content-type: text/plain < date: Thu, 17 Jul 2025 17:32:41 GMT < content-length: 48 < Welcome john! Email: john@example.com Role: user ``` If you try to access the protected route without providing valid credentials, you will receive a `401 Unauthorized` response: ```bash $ curl -v -X GET http://127.0.0.1:8080/profile/me > GET /profile/me HTTP/1.1 > Host: 127.0.0.1:8080 > User-Agent: curl/8.4.0 > Accept: */* > < HTTP/1.1 401 Unauthorized < www-authenticate: Basic realm="Protected API", UTF-8="UTF-8" < date: Thu, 17 Jul 2025 17:34:27 GMT < content-length: 23 < * Connection #0 to host 127.0.0.1 left intact Authentication required⏎ ``` The `www-authenticate: Basic realm="Protected API"` header indicates that the server expects Basic Authentication credentials. ## Writing a Client to Test Basic Authentication ### ZIO HTTP Client We can create a simple client to test the Basic Authentication implementation: ```scala object AuthenticationClient extends ZIOAppDefault { val profileMeUrl = URL.decode(s"http://localhost:8080/profile/me").toOption.get val program = for { _ <- Console.printLine(s"=== Accessing profile/me with john's credentials ===") response <- ZClient.batched(Request.get(profileMeUrl).addHeader(Header.Authorization.Basic("john", "secret123"))) body <- response.body.asString _ <- Console.printLine(s"Status: ${response.status}") _ <- Console.printLine(s"Response: $body") } yield () override val run = program.provide(Client.default) } ``` By adding the `Header.Authorization.Basic("john", "secret123")` header, the ZIO HTTP Client will encode the credentials and send them in the request. ### Web Client To create a simple web client that interacts with the Basic Authentication endpoint, we can use HTML and JavaScript. Below is an example of a basic HTML page that allows users to enter their username and password, and then fetch their profile using Basic Authentication: ```html // src/main/resources/basic-auth-client.html Basic Authentication Basic Authentication ``` We used `btoa()` to encode the username and password in Base64 format, which is required for Basic Authentication. When the user clicks the "Get My Profile" button, it sends a request to the `/profile/me` endpoint with the provided credentials. Now, it's time to serve this HTML page using ZIO HTTP. We can create a simple server that serves this HTML file. First, make sure to place the HTML file in the resources directory of our project, for example, `src/main/resources/basic-auth-client.html`. Then, let's add the following route to the existing `routes`: ```scala val routes = Routes( Method.GET / Root -> handler { (_: Request) => ZStream .fromResource("basic-auth-client.html") .via(ZPipeline.utf8Decode) .runCollect .map(_.mkString) .map { htmlContent => Response( status = Status.Ok, headers = Headers(Header.ContentType(MediaType.text.html)), body = Body.fromString(htmlContent), ) } .orElseFail( Response.internalServerError("Failed to load HTML file"), ) } // other routes ... ) ``` Now if we run the server and open localhost:8080 in a web browser, we can enter the username and password to fetch the profile information using Basic Authentication. ## Custom User Service Our first implementation used an immutable in-memory map for user storage. In a real-world application, we would typically have a separate user service that interacts with a database or an external authentication provider. So, let's create a separate user service that can have different implementations, such as in-memory, database-backed, or even an external API. ```scala case class User( username: String, password: Secret, email: String, role: String ) trait UserService { def authenticate(username: String, password: Secret): UIO[Option[User]] } case class InMemoryUserService(private val users: Ref[Map[String, User]]) extends UserService { def authenticate(username: String, password: Secret): UIO[Option[User]] = users.get.map(_.get(username).find(user => user.password == password)) } object InMemoryUserService { def make(users: Map[String, User]): UIO[UserService] = Ref.make(users).map(new InMemoryUserService(_)) private val users = Map( "john" -> User("john", Secret("secret123"), "john@example.com", "user"), "jane" -> User("jane", Secret("password456"), "jane@example.com", "user"), "admin" -> User("admin", Secret("admin123"), "admin@example.com", "admin"), ) val live: ZLayer[Any, Nothing, UserService] = ZLayer.fromZIO(make(users)) } ``` Now we can use this `UserService` in our authentication middleware: ```scala object AuthenticationServer extends ZIOAppDefault { val basicAuthWithUserContext: HandlerAspect[UserService, User] = HandlerAspect.interceptIncomingHandler(Handler.fromFunctionZIO[Request] { request => ZIO.serviceWithZIO[UserService] { userService => request.header(Header.Authorization) match { case Some(Header.Authorization.Basic(username, password)) => userService.authenticate(username, password).flatMap { case Some(user) => ZIO.succeed((request, user)) case None => ZIO.fail( Response .unauthorized("Invalid username or password") .addHeaders(Headers(Header.WWWAuthenticate.Basic(realm = Some("Access")))), ) } case _ => ZIO.fail( Response .unauthorized("Authentication required") .addHeaders(Headers(Header.WWWAuthenticate.Basic(realm = Some("Access")))), ) } } }) def routes: Routes[UserService, Response] = Routes( // Public route - no authentication required Method.GET / "public" -> handler { (_: Request) => ZIO.succeed(Response.text("This is a public endpoint accessible to everyone")) }, // Route that uses the full User object Method.GET / "profile" / "me" -> handler { (_: Request) => ZIO.serviceWith[User](user => Response.text(s"Welcome ${user.username}!\nEmail: ${user.email}\nRole: ${user.role}"), ) } @@ basicAuthWithUserContext, ) @@ Middleware.debug override val run = Server.serve(routes).provide(Server.default, InMemoryUserService.live) } ``` In this example, we demonstrated writing middleware that uses a `UserService` to authenticate users and return the user as part of the request context. This allows for a more modular and testable approach to authentication, as we can easily swap out the `UserService` implementation without changing the middleware logic. ## Security Best Practices While it is simple to implement, there are some security concerns with Basic Authentication, so we should follow several security best practices to ensure the safety of our application and its users. Here are some key recommendations: ### 1. Use HTTPS Only The credentials are sent in an easily decodable format, which means they can be intercepted if not transmitted over a secure channel (HTTPS). Therefore, it is crucial to use Basic Authentication only over HTTPS to protect the credentials from being exposed during transmission. ### 2. Password Hashing Another important aspect is that the server should not store passwords in plain text. If the database is compromised, attackers can easily access user credentials. So, we should use secure hashing algorithms to store hashed passwords, and compare the hashed version of the provided password with the stored hash. To store users securely without the risk of compromising the user's passwords, the system first receives a username and password during registration. It then hashes the password using a hash function and stores the username along with the resulting hashed password in a user database. For authentication, the system receives a username and password from the user trying to log in. It looks up the user by username in the database. If the user exists, the system hashes the entered password using the same hash function and compares it to the stored hashed password. If they match, the login is successful; otherwise, it fails. If the username is not found in the database, the login fails immediately. This approach is simple and easy to implement, but insecure for real-world use. Without salting or a slow hashing algorithm like bcrypt or argon2, it is vulnerable to brute-force and rainbow table attacks. A rainbow table is a precomputed list of common passwords and their hash values. If we store hashes without a salt (a random value added to the password before hashing), then: - Anyone with access to our database can match the hash to known passwords instantly. - Two users with the same password will have identical hashes, revealing password reuse. Also, not all hashing methods are suitable for storing passwords. Fast hashing algorithms like SHA-256 or MD5 are not suitable because they allow attackers to try many passwords quickly. Instead, use a slow hashing algorithm like **bcrypt**, **scrypt**, or **argon2**, which are designed for securely hashing passwords. So, never store passwords in plain text. Always use proper password hashing with salt. ### 3. Implement Rate Limiting To make our Basic Authentication more secure, it is recommended to implement rate limiting to prevent brute force attacks. Rate limiting restricts the number of requests a user can make in a given time period, which helps mitigate the risk of attackers trying to guess passwords through repeated attempts. As the details of the implementation are out of scope for this article, we only provide a simple interface for such middleware: ```scala def rateLimit(maxRequests: Int, windowSeconds: Int): Middleware[Any] = { // Implementation would track requests per IP and time window } ``` We will discuss rate limiting in more detail in a separate article. ## Source Code The complete source code for this Basic Authentication example is available in the ZIO HTTP examples repository. The example includes both server and client implementations, along with an interactive web-based demo. To clone the example: ```scala git clone --depth 1 --filter=blob:none --sparse https://github.com/zio/zio-http.git cd zio-http git sparse-checkout set zio-http-example-basic-auth ``` The example contains the following files: - **AuthenticationServer.scala** - The server implementation with secure password hashing - **AuthenticationClient.scala** - A simple ZIO HTTP-based client demonstrating API calls - **basic-auth-client.html** - An interactive web interface for testing (in resources) - **basic-auth-client-simple.html** - A simplified interactive web interface for testing (in resources) ### Running the Server To run the authentication server: ```bash cd zio-http sbt "zio-http-example/runMain example.auth.basic.AuthenticationServer" ``` The server will start on `http://localhost:8080` with the following test users: | Username | Password | Role | Description | |----------|---------------|-------|----------------------------------------| | `john` | `secret123` | user | Standard user account | | `jane` | `password456` | user | Standard user account | | `admin` | `admin123` | admin | Admin account with elevated privileges | ### ZIO HTTP Client To run the command-line client (make sure the server is running first): ```bash sbt "zio-http-example/runMain example.auth.basic.AuthenticationClient" ``` The client will demonstrate: 1. Accessing a public endpoint (no authentication) 2. Accessing a protected profile endpoint with valid credentials 3. Accessing an admin-only endpoint Here is an example output from the client: ``` === Basic Authentication Client Example === --- Accessing public endpoint --- Status: Ok Response: This is a public endpoint accessible to everyone --- Accessing profile/me with john's credentials --- Status: Ok Response: Welcome john! Email: john@example.com Role: user --- Accessing admin/users with admin credentials --- Status: Ok Response: User List (accessed by admin): john (john@example.com) - Role: user jane (jane@example.com) - Role: user admin (admin@example.com) - Role: admin === All requests completed === ``` ### Web-based Client After starting the server, open your web browser and navigate to: ``` http://localhost:8080 ``` This will load an interactive web interface where you can: - Select from pre-configured test users - See real-time HTTP request/response messages - Test different endpoints with various credentials - Observe authentication success and failure scenarios ## Demo We have deployed a live demo of the Basic Authentication example application. You can access it at: [https://basic-auth-demo.ziohttp.com/](https://basic-auth-demo.ziohttp.com/). By using this demo, you can explore the functionality of Basic Authentication implemented with ZIO HTTP. You can see the HTTP traffic in your browser's developer tools to understand how the authentication process works. ## Conclusion Through this guide, we've explored how to implement authentication middleware, create protected routes, and integrate user services in a modular and testable way. While Basic Authentication is simple to implement and understand, it's crucial to follow security best practices in production environments. Always use HTTPS to protect credentials in transit, implement proper password hashing with salt using algorithms like bcrypt or argon2, and consider additional security measures such as rate limiting to prevent brute force attacks. For applications requiring more sophisticated authentication mechanisms, consider exploring other options like JWT tokens, OAuth 2.0, or session-based authentication. --- ## Securing Your APIs: Cookie-based Authentication Session-based authentication using cookies is one of the most common authentication mechanisms for web applications. In this guide, we demonstrate how to implement a robust cookie-based authentication system in ZIO HTTP, covering both server-side implementation and client integration. In this authentication model, when a user logs in successfully, the server creates a session and sends a session identifier to the client as a cookie. The client automatically includes this cookie in subsequent requests, allowing the server to identify and authenticate the user. ## Understanding Set-Cookie and Cookie Headers The foundation of cookie-based authentication lies in two HTTP headers: `Set-Cookie` (server to client) and `Cookie` (client to server). Understanding these headers is crucial for implementing secure authentication. ### The Set-Cookie Header (Server → Client) The `Set-Cookie` header is used by the server to send cookies to the client. When a user successfully authenticates, the server creates a session and sends the session identifier to the client using this header. In ZIO HTTP, we can create a `Set-Cookie` header using the `Cookie.Response` data type: ```scala val cookie = Cookie.Response( name = "session_id", // Cookie name content = "abc123def456", // Cookie value (session ID) domain = Some("example.com"), // Cookie scope by domain path = Some(Path.root), // Cookie scope by path isSecure = true, // HTTPS only isHttpOnly = true, // Not accessible via JavaScript maxAge = Some(3600.seconds), // Lifetime in seconds sameSite = Some(Cookie.SameSite.Strict) // CSRF protection ) ``` Here is a concise breakdown of the attributes used in the `Cookie.Response`: - **`name`**: The name of the cookie (e.g., `session_id`). - **`content`**: The value of the cookie, typically a session identifier. - **`domain`**: The domain for which the cookie is valid. It defines which hosts (subdomains) can receive the cookie. If no domain is specified, the cookie is valid for the host that set it. If a domain is specified, the cookie will be sent to that domain and its subdomains. - **`path`**: The path for which the cookie is valid. The server can include this attribute to restrict the cookie to specific paths. If no path is specified, the cookie is sent only to the same path as the resource that set it and its subdirectories. If a path is specified, the cookie will be sent to that path and its subdirectories. - **`isSecure`**: If `true`, the cookie is only sent over HTTPS connections. This prevents the cookie from being transmitted over unencrypted HTTP, enhancing security. - **`isHttpOnly`**: If `true`, the cookie is not accessible via JavaScript, mitigating the risk of cross-site scripting (XSS) attacks. - **`maxAge`**: The maximum age of the cookie in seconds. After this time, the cookie will be deleted by the browser. If not specified, the cookie is a **session cookie** and will be deleted when the browser is closed. - **`sameSite`**: Controls whether the cookie is sent with cross-site requests. The `SameSite` attribute can be set to `Strict`, `Lax`, or `None`. Setting it to `Strict` provides the highest level of CSRF protection, while `Lax` allows some cross-site requests (e.g., top-level navigations). We can use this cookie in the login flow. When a user provides valid credentials, we create a session and send it back to the client as a `Set-Cookie` header: ```scala if (user.password == Secret(password)) { Response .text(s"Login successful! Session created for $username") .addCookie(cookie) } else Response.unauthorized("Invalid username or password.") ``` ### The Cookie Header (Client → Server) After the server sends the `Set-Cookie` header, the client (usually the browser) stores the cookie and automatically includes it in subsequent requests to the same domain. The client sends this cookie using the `Cookie` header. For example, if we have a cookie named `session_id`, the client will include it in requests like this: ```scala GET /profile/me HTTP/1.1 Host: localhost:8080 Cookie: session_id=0f547819-2bde-4405-8ea5-986954bc9ee6 ``` In this example, the client sends the `session_id` cookie to the server when accessing the `/profile/me` endpoint. The server can then validate this session ID to authenticate the user. In ZIO HTTP, when writing client code, we don't need to manually create a `Cookie` header; instead, we can convert the received `Set-Cookie` header into a `Cookie` object and use it in subsequent requests: ```scala for { loginResponse <- ZClient.batched( Request .post( url = loginUrl, body = Body.fromURLEncodedForm( Form( FormField.simpleField("username", "john"), FormField.simpleField("password", "password123"), ), ), ), ) cookie = loginResponse.headers(Header.SetCookie).head.value.toRequest _ <- Console.printLine("Accessing protected route...") greetResponse <- ZClient.batched(Request.get(profileUrl).addCookie(cookie)) } yield () ``` ## Implementation In this section, we will implement a complete cookie-based authentication system using ZIO HTTP. Before we start, we need to define and implement some services that will help us manage user sessions and user accounts. 1. **Session Service**: This service will manage user sessions, allowing us to create, retrieve, and remove sessions. 2. **User Service**: This service will manage user accounts, allowing us to retrieve and create users. We need the user service to validate user credentials during login and also to retrieve user profile information. Let's first implement these two services. ### 1. Session Service Here is a simple in-memory session service that manages user sessions. It allows creating, retrieving, and removing sessions: ```scala class SessionService private(private val store: Ref[Map[String, String]]) { private def generateSessionId(): UIO[String] = ZIO.randomWith(_.nextUUID).map(_.toString) def create(username: String): UIO[String] = for { sessionId <- generateSessionId() _ <- store.update(_ + (sessionId -> username)) } yield sessionId def get(sessionId: String): UIO[Option[String]] = store.get.map(_.get(sessionId)) def remove(sessionId: String): UIO[Unit] = store.update(_ - sessionId) } ``` Here is how to create a live layer for the `SessionService`: ```scala object SessionService { def live: ZLayer[Any, Nothing, SessionService] = ZLayer.fromZIO { Ref.make(Map.empty[String, String]).map(new SessionService(_)) } } ``` ### 2. User Service The user service manages user accounts, allowing retrieval and creation of users. It also handles errors related to user operations, such as user not found or user already exists: ```scala case class User(username: String, password: Secret, email: String) sealed trait UserServiceError object UserServiceError { case class UserNotFound(username: String) extends UserServiceError case class UserAlreadyExists(username: String) extends UserServiceError } trait UserService { def getUser(username: String): IO[UserNotFound, User] def addUser(user: User): IO[UserAlreadyExists, Unit] def updateEmail(username: String, newEmail: String): IO[UserNotFound, Unit] } case class UserServiceLive(users: Ref[Map[String, User]]) extends UserService { def getUser(username: String): IO[UserNotFound, User] = users.get.flatMap { userMap => ZIO.fromOption(userMap.get(username)).orElseFail(UserNotFound(username)) } def addUser(user: User): IO[UserAlreadyExists, Unit] = users.get.flatMap { userMap => ZIO.when(userMap.contains(user.username)) { ZIO.fail(UserAlreadyExists(user.username)) } *> users.update(_.updated(user.username, user)) } def updateEmail(username: String, newEmail: String): IO[UserNotFound, Unit] = for { currentUsers <- users.get user <- ZIO.fromOption(currentUsers.get(username)).orElseFail(UserNotFound(username)) _ <- users.update(_.updated(username, user.copy(email = newEmail))) } yield () } ``` To create a live layer for the `UserService`, let's initialize it with some predefined users: ```scala object UserService { private val initialUsers = Map( "john" -> User("john", Secret("password123"), "john@example.com"), "jane" -> User("jane", Secret("secret456"), "jane@example.com"), "admin" -> User("admin", Secret("admin123"), "admin@company.com"), ) val live: ZLayer[Any, Nothing, UserService] = ZLayer.fromZIO { Ref.make(initialUsers).map(UserServiceLive(_)) } } ``` Now we have all the required services to write the login route and then implement cookie-based authentication middleware. The next step is to implement the login route that will authenticate users and create sessions. ### Login Route The login route is responsible for receiving user credentials (username and password), validating them, and creating a session if the credentials are correct: 1. **Parse and validate** - Extracts username and password from URL-encoded form data, returning bad request errors if fields are missing 2. **Authenticate** - Retrieves user from `UserService` and verifies the password matches, returning unauthorized if user not found or password incorrect 3. **Create session** - Generates a session ID via `SessionService` and attaches a session cookie with security settings to the success response ```scala val login = Method.POST / "login" -> handler { (request: Request) => val form = request.body.asURLEncodedForm.orElseFail(Response.badRequest("Invalid form data")) for { username <- form .map(_.get("username")) .flatMap(ff => ZIO.fromOption(ff).orElseFail(Response.badRequest("Missing username field!"))) .flatMap(ff => ZIO.fromOption(ff.stringValue).orElseFail(Response.badRequest("Missing username value!"))) password <- form .map(_.get("password")) .flatMap(ff => ZIO.fromOption(ff).orElseFail(Response.badRequest("Missing password field!"))) .flatMap(ff => ZIO.fromOption(ff.stringValue).orElseFail(Response.badRequest("Missing password value!"))) users <- ZIO.service[UserService] user <- users .getUser(username) .orElseFail( Response.unauthorized("Invalid username or password."), ) res <- if (user.password == Secret(password)) { for { sessionService <- ZIO.service[SessionService] sessionId <- sessionService.create(username) cookie = Cookie.Response( name = "session_id", content = sessionId, maxAge = Some(300.seconds), isHttpOnly = false, // Set to true in production to prevent XSS attacks isSecure = false, // Set to true in production with HTTPS sameSite = Some(Cookie.SameSite.Strict), ) } yield Response .text(s"Login successful! Session created for $username") .addCookie(cookie) } else ZIO.fail(Response.unauthorized("Invalid username or password.")) } yield res } ``` :::note In production environments, we should set `isHttpOnly = true` and `isSecure = true` for the session cookie to enhance security. This prevents client-side scripts from accessing the cookie and ensures it is only sent over secure HTTPS connections. ::: ### Authentication Middleware After implementing the login route, we can now create middleware that will intercept incoming requests and check for a valid session cookie. If the cookie is present and valid, it will allow access to protected resources; otherwise, it will return an unauthorized response. We can write it as a `HandlerAspect` like this: ```scala object AuthMiddleware { def cookieAuth(cookieName: String = "session_id"): HandlerAspect[SessionService & UserService, User] = HandlerAspect.interceptIncomingHandler { Handler.fromFunctionZIO[Request] { request => ZIO.serviceWithZIO[SessionService] { sessionService => request.cookie(cookieName) match { case Some(cookie) => sessionService.get(cookie.content).flatMap { case Some(username) => ZIO .serviceWithZIO[UserService](_.getUser(username)) .map(u => (request, u)) .orElseFail( Response.unauthorized(s"User not found!"), ) case None => ZIO.fail(Response.unauthorized("Invalid or expired session!")) } case None => ZIO.fail(Response.unauthorized("No session cookie found!")) } } } } } ``` If the cookie is present and valid, it retrieves the associated user and allows access to protected resources by passing the original request along with the authenticated user to the downstream handlers. If not, it returns an unauthorized response. ## Applying Middleware This middleware can be applied to any route that requires authentication. For example, to protect a user profile route, we can use it like this: ```scala val profile = Method.GET / "profile" / "me" -> handler { (_: Request) => ZIO.serviceWith[User](user => Response.text( s"Welcome ${user.username}! " + s"This is your profile: \n Username: ${user.username} \n Email: ${user.email}", ), ) } @@ AuthMiddleware.cookieAuth("session_id") ``` ## Writing a ZIO HTTP Client While web browsers handle cookies automatically, when building a programmatic client using ZIO HTTP, we need to explicitly manage cookies in our requests. This section demonstrates how to build a ZIO HTTP client that can authenticate with our cookie-based server and access protected resources. Unlike browser-based clients where cookies are automatically stored and sent, a ZIO HTTP client requires explicit cookie management: 1. **Sending credentials** to the login endpoint 2. **Extracting the cookie** from the `Set-Cookie` response header 3. **Including the cookie** in subsequent requests to protected endpoints Let's build a client that authenticates with our server and accesses protected resources. ### Step 1: Making a Login Request To authenticate, we send a POST request with URL-encoded form data containing the username and password: ```scala val SERVER_URL = "http://localhost:8080" val loginUrl = URL.decode(s"$SERVER_URL/login").toOption.get val loginRequest = Request .post( url = loginUrl, body = Body.fromURLEncodedForm( Form( FormField.simpleField("username", "john"), FormField.simpleField("password", "password123"), ), ), ) val loginResponse = ZClient.batched(loginRequest) ``` The `Body.fromURLEncodedForm` helper creates the appropriate body with the `application/x-www-form-urlencoded` content type, matching what our server expects. ### Step 2: Extracting the Session Cookie After successful authentication, the server responds with a `Set-Cookie` header containing our session cookie. We need to extract this cookie and convert it for use in subsequent requests: ```scala for { res <- loginResponse cookie = res.headers(Header.SetCookie).head.value.toRequest } yield () ``` The `toRequest` method converts a `Cookie.Response` (used in `Set-Cookie` headers) to a `Cookie.Request` (used in `Cookie` headers), handling all the necessary format conversions. :::note In the client we have written, we only handle a single cookie for session management. However, in real-world applications, we might need to manage multiple cookies, such as those for CSRF protection or other session-related data. In such scenarios, we should extract all cookies from the response and manage them accordingly. ::: ### Step 3: Using the Cookie for Protected Routes With the session cookie in hand, we can now access protected endpoints by including the cookie in our requests: ```scala val cookie = Cookie.Request(name = "foo", content = "bar") ``` ```scala val profileUrl = URL.decode(s"$SERVER_URL/profile/me").toOption.get val protectedRequest = Request .get(profileUrl) .addCookie(cookie) val profileResponse = ZClient.batched(protectedRequest) ``` The `addCookie` method adds the cookie to the request's `Cookie` header, authenticating our request with the server. ### Complete Client Implementation Here's a complete example that demonstrates the full authentication lifecycle: ```scala title="zio-http-example-cookie-auth/src/main/scala/example/auth/session/cookie/AuthenticationClient.scala" package example.auth.session.cookie object AuthenticationClient extends ZIOAppDefault { /** * This example is trying to access a protected route by first making a login * request to obtain a session cookie and use it to access a protected route. * Run CookieAuthenticationServer before running this example. */ private val SERVER_URL = "http://localhost:8080" private val loginUrl = URL.decode(s"$SERVER_URL/login").toOption.get private val profileUrl = URL.decode(s"$SERVER_URL/profile/me").toOption.get private val logoutUrl = URL.decode(s"$SERVER_URL/logout").toOption.get val program = for { // Making a login request to obtain the session cookie. In this example the password should be the reverse string of username. _ <- Console.printLine("Making login request...") loginResponse <- ZClient.batched( Request .post( url = loginUrl, body = Body.fromURLEncodedForm( Form( FormField.simpleField("username", "john"), FormField.simpleField("password", "password123"), ), ), ), ) loginBody <- loginResponse.body.asString _ <- Console.printLine(s"Login response: $loginBody") cookie = loginResponse.headers(Header.SetCookie).head.value.toRequest _ <- Console.printLine("Accessing protected route...") greetResponse <- ZClient.batched(Request.get(profileUrl).addCookie(cookie)) greetBody <- greetResponse.body.asString _ <- Console.printLine(s"Protected route response: $greetBody") // Demonstrate logout _ <- Console.printLine("Logging out...") logoutResponse <- ZClient.batched(Request.get(logoutUrl).addCookie(cookie)) logoutBody <- logoutResponse.body.asString _ <- Console.printLine(s"Logout response: $logoutBody") // Try to access protected route again after logout (should fail) _ <- Console.printLine("Trying to access protected route after logout...") finalResponse <- ZClient.batched(Request.get(profileUrl).addCookie(cookie)) finalBody <- finalResponse.body.asString _ <- Console.printLine(s"Final response: $finalBody") _ <- Console.printLine(s"Final response status: ${finalResponse.status}") } yield () override val run = program.provide(Client.default) } ``` ## Writing a Web Client In this section, we will implement a simple web client that interacts with our cookie-based authentication server. The client will allow users to log in, retrieve their profile, and log out using cookies for session management. ### Logging In First, we need to write a login form to ask the user for their credentials. This form will submit the username and password to the server, which will then create a session and send back a cookie: ```html ``` To handle the login, we can write a simple JavaScript function that sends the credentials to the server and handles the response: ```javascript function setLoginState(isLoggedIn) { loginForm.classList.toggle('hide', isLoggedIn); } async function login() { try { const res = await fetch('/login', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: `username=${user.value}&password=${password.value}` }); result.textContent = await res.text(); setLoginState(res.ok); } catch(e) { result.textContent = `Error: ${e.message}`; setLoginState(false); } } ``` The `login` function sends a POST request to the `/login` endpoint with the username and password. If the response is successful, it toggles the login form to hide it. The `hide` class is a CSS class that hides the element: ```css .hide { display: none; } ``` Upon successful login, the browser stores the received cookie automatically. This received cookie will be used to authenticate subsequent requests to the server. The flow is simple: Login → Server creates session → Server sends cookie → Browser stores cookie → Browser sends cookie with every request → Server validates cookie → Server responds with protected data. The next step is to make authenticated requests to the server using the received session cookie. ### Making Authenticated Requests Unlike token-based authentication where we manually store and attach tokens, cookies are handled automatically by the browser, making the client implementation much simpler. To fetch the user profile after logging in, let's write a button that triggers an authenticated request to the server: ```html
Results will appear here...
``` We also added a section for displaying the result of the request. By default, the "Get Profile" button is hidden until the user logs in. When clicked, it calls the `getProfile` function: ```javascript async function getProfile() { try { const res = await fetch('/profile/me'); result.textContent = await res.text(); } catch(e) { result.textContent = `error: ${e.message}`; } } ``` As the `getProfile` function shows, we make a GET request to the `/profile/me` endpoint using the `fetch()` function. By default, the session cookie is sent along with the request, so no additional handling is needed to include the cookie in the request. :::note To have more control over cookie handling, we can specify the `credentials` option in the `fetch` call. For example, the following code snippet will ensure that cookies are sent with the request even for cross-origin requests: ```javascript fetch('/api/endpoint', { credentials: 'include' }) ``` In JavaScript, the `credentials` option of the `fetch` method can accept three possible values: - **`omit`** - Never send cookies, HTTP authentication, or client certificates with the request, even for same-origin requests. This is the most restrictive option. - **`same-origin`** - Only send credentials (cookies, HTTP authentication, client certificates) when the request is to the same origin. This is the default value if we don't specify the `credentials` option. - **`include`** - Always send credentials with the request, even for cross-origin requests. This is necessary when we need to send cookies or authentication headers to a different domain. ::: Now that we've added a div called `loggedIn` which contains all the elements that need to be visible after logging in, let's update the `setLoginState` function: ```javascript function setLoginState(isLoggedIn) { loginForm.classList.toggle('hide', isLoggedIn); loggedIn.classList.toggle('hide', !isLoggedIn); } ``` This toggles both the login form and logged-in-related elements and is called after each call to the login and logout endpoints. ### Checking Session Status on Page Load Users expect to stay logged in when they refresh the page or return later. On page load, we can check if the user is logged in by making a request to a protected endpoint, e.g., `/profile/me`: ```javascript window.onload = async () => { try { const res = await fetch('/profile/me'); const text = await res.text(); if (res.ok) { result.textContent = 'Session is active!'; setLoginState(true); } else { result.textContent = text; setLoginState(false); } } catch(e) { result.textContent = `Error: ${e.message}`; setLoginState(false); } }; ``` This pattern prevents the login form from flashing before showing authenticated content. It's better UX to check authentication first, then render the appropriate UI. We may want to show a loading spinner during this check. Please note that for simplicity, we used the same `/profile/me` endpoint for checking session status, which also returns user details if authenticated. In real applications, we might want to have a dedicated endpoint for checking session status without returning user details. ### Logging Out To log out, let's add a corresponding button: ```html
Results will appear here...
``` Clearing the cookie on the client side is not enough, and it is good practice to call the server to invalidate the session. This ensures that the session is removed from the server-side store, preventing any further access with that session ID and also returning a cookie with an expired `maxAge` to the client, which will invalidate the stored cookie in the browser: ```javascript async function logout() { try { const res = await fetch('/logout'); result.textContent = await res.text(); setLoginState(!res.ok); } catch(e) { result.textContent = `Error: ${e.message}`; } } ``` After successful logout, the client calls `setLoginState` to make the login form visible again and hide the logged-in section. ### Complete Client Implementation Here is the complete HTML code that includes the login form, profile retrieval, and logout functionality: ```html Cookie-Based Authentication Demo Cookie-Based Authentication Demo
Results will appear here...
``` After placing this HTML file in our resources directory and naming it `cookie-based-auth-client-simple.html`, we can serve it using the following ZIO HTTP route: ```scala val route = Method.GET / Root -> Handler .fromResource("cookie-based-auth-client-simple.html") .orElse(Handler.internalServerError("Failed to load HTML file")) ``` ## Security Best Practices When implementing cookie-based authentication, it is crucial to follow security best practices to protect user sessions and sensitive data. Here are some key recommendations: Cookie security forms the foundation of a robust authentication system. Always set the `isHttpOnly` flag to true for session cookies to prevent client-side JavaScript access, and never store sensitive authentication data in localStorage or sessionStorage as these are vulnerable to XSS attacks. The `isSecure` flag should be enabled to ensure cookies are only transmitted over HTTPS connections, and you should always use HTTPS in production environments to protect data in transit. Additionally, configure the `SameSite` attribute with either `Strict` or `Lax` settings to provide CSRF protection by default, and combine this with CSRF tokens for defense in depth. Implement CSRF tokens for all state-changing operations, including POST, PUT, and DELETE requests, alongside `SameSite` cookies for comprehensive protection. Session management requires careful attention to multiple security considerations. Implement both idle timeout and absolute timeout for sessions to balance security and user experience, and provide secure session renewal mechanisms before expiration to maintain user sessions without requiring re-authentication. Ensure proper session destruction on logout by clearing both server-side session data and client-side cookies. Infrastructure security completes the authentication security triad. Implement rate limiting on authentication endpoints to prevent brute force and denial-of-service attacks. For password security, always use secure password hashing algorithms like bcrypt, scrypt, or Argon2 instead of storing raw passwords, which would be a critical vulnerability. ## Source Code The complete source code for this Cookie-Based Authentication example is available in the ZIO HTTP examples repository. To clone the example: ```bash git clone --depth 1 --filter=blob:none --sparse https://github.com/zio/zio-http.git cd zio-http git sparse-checkout set zio-http-example-cookie-auth ``` ### Running the Server To run the authentication server: ```bash cd zio-http/zio-http-example-cookie-auth sbt "runMain example.auth.session.cookie.AuthenticationServer" ``` The server starts on `http://localhost:8080` with these test users: | Username | Password | Email | |----------|---------------|----------------------| | `john` | `password123` | john@example.com | | `jane` | `secret456` | jane@example.com | | `admin` | `admin123` | admin@company.com | ### ZIO HTTP Client Run the command-line client (ensure server is running): ```bash cd zio-http/zio-http-example-cookie-auth sbt "runMain example.auth.session.cookie.AuthenticationClient" ``` Example output: ``` Making login request... Login response: Login successful! Session created for john Accessing protected route... Protected route response: Welcome john! This is your profile: Username: john Email: john@example.com Logging out... Logout response: Logged out successfully! Trying to access protected route after logout... Final response: Invalid or expired session! Final response status: Unauthorized ``` ### Web-Based Client Navigate to `http://localhost:8080` to access the interactive web interface. The advanced client (default) includes: - User selection cards with pre-configured credentials - Real-time session status indicator - HTTP transaction viewer showing requests/responses - Cookie information display To use the simple version, modify `AuthenticationServer.scala`: ```scala Method.GET / Root -> Handler .fromResource("cookie-based-auth-client-simple.html") .orElse(Handler.internalServerError("Failed to load HTML file")) ``` ## Demo We have deployed a live demo at: [https://cookie-auth-demo.ziohttp.com/](https://cookie-auth-demo.ziohttp.com/) **Try these scenarios:** 1. **Normal Login Flow** - Log in and observe Set-Cookie header, then access profile to see Cookie header automatically sent 2. **Session Persistence** - Log in, refresh the page, notice you remain logged in 3. **Session Expiration** - Log in, wait 5 minutes, try accessing profile (fails with "Invalid or expired session") 4. **Proper Logout** - Log in, click Logout, observe cookie clearing, then try accessing profile (fails) Use your browser's developer tools (Network tab) to examine cookie behavior and attributes in detail. ## Conclusion In this guide, we've implemented a complete cookie-based authentication system in ZIO HTTP, demonstrating how to build secure session management for web applications. We covered the essential components: creating a session service for managing active sessions, implementing a user service for account management, building a login endpoint that generates secure session cookies, and developing authentication middleware to protect routes. --- ## Securing Your APIs: Digest Authentication Digest Authentication provides enhanced security over Basic Authentication by addressing fundamental vulnerabilities in credential transmission. This implementation guide demonstrates how to build Digest Authentication using ZIO HTTP, covering both server-side middleware and client-side integration patterns. ## Understanding Digest Authentication Digest Authentication, standardized in RFC 2617 and extended in RFC 7616, implements an HTTP authentication scheme utilizing a **challenge-response mechanism with cryptographic hashing**. Unlike Basic Authentication, which transmits credentials in Base64 encoding, Digest Authentication employs hash functions to generate cryptographic digests that verify credential knowledge without exposing plaintext passwords over the network. ### The Challenge-Response Protocol Flow The authentication protocol follows this standardized sequence: 1. **Initial Request**: The client initiates a request to a protected resource. 2. **Authentication Challenge**: The server responds with `401 Unauthorized` status and a `WWW-Authenticate` header containing: - `realm`: A string identifying the protection space - `nonce`: A server-generated cryptographic nonce. Nonces are unique values that change for each authentication challenge to ensure that each request is authenticated only once, preventing replay attacks - `qop`: Quality of Protection specification (typically "auth" or "auth-int", or supporting both). This parameter defines the protection level for the request, indicating whether only authentication is required or if message integrity protection is also needed - `algorithm`: Cryptographic hash algorithm specification (MD5, SHA-256, etc.) 3. **Challenge Response**: The client computes a cryptographic digest using the challenge parameters and credentials, then resubmits the request with an `Authorization` header containing the response to the challenge. 4. **Authentication Verification**: The server validates the digest and either grants access or denies authorization. The following examples illustrate the complete authentication handshake: 1. **Initial Resource Access Attempt**: The client requests a protected resource at `/profile/me` without authentication credentials: ```http GET /profile/me HTTP/1.1 Host: localhost:8080 ``` 2. **Server Authentication Challenge**: The server responds with "401 Unauthorized" and presents an authentication challenge: ```http HTTP/1.1 401 Unauthorized content-length: 0 date: Thu, 24 Jul 2025 07:27:40 GMT www-authenticate: Digest realm="User Profile", nonce="MTc1MzM0MjA2MDI0NDpmbXNGK2dTblF4WEVwN1gwWktMVllRPT0=", opaque="uSla+F7cMBsB/t3K9OCLzg==", stale=false, algorithm=MD5, qop="auth", charset=UTF-8, userhash=false ``` The `WWW-Authenticate` header delivers the authentication challenge within an HTTP 401 Unauthorized response, indicating that the requested resource requires authentication within the specified protection realm and mandating cryptographic digest computation using the provided parameters. 3. **Cryptographic Digest Computation**: The client computes a cryptographic digest response using the specified hash algorithm applied to a structured combination of challenge parameters and user credentials. This process generates a hash value that cryptographically proves credential possession without transmitting plaintext passwords. The digest response calculation follows this standardized algorithm: ```http HA1 = H(username:realm:password) if (qop == "auth-int") HA2 = H(method:uri:body) else HA2 = H(method:uri) response = H(HA1:nonce:nc:cnonce:qop:HA2) ``` **Parameter Definitions:** - **`username`**: The user identifier for authentication - **`realm`**: A server-defined protection space identifier that logically partitions protected resources - **`password`**: The user's authentication secret - **`method`** and **`uri`**: The HTTP request method (GET, POST, etc.) and the requested URI path. Including these values cryptographically binds the digest to the specific request, preventing attackers from reusing valid digests for different requests (mitigates request tampering) - **`nonce`**: A server-generated, temporally-limited unique value that varies for each authentication challenge. This server-controlled parameter ensures temporal uniqueness and prevents replay attacks by invalidating previously captured authentication attempts - **`nc` (nonce count)**: A hexadecimal counter (initialized at 00000001) that increments with each request utilizing the same nonce value. This enables the server to detect duplicate or out-of-sequence requests, providing protection against replay attacks even when nonces are reused during their validity period - **`cnonce` (client nonce)**: A client-generated cryptographic nonce that contributes entropy from the client side. This parameter ensures that identical server challenges produce different digest responses, preventing certain cryptographic attacks and enabling client request correlation - **`qop`** (Quality of Protection): Defines the protection level - "auth" for authentication-only or "auth-int" for authentication with message integrity protection The digest authentication mechanism provides several cryptographic security enhancements over HTTP Basic Authentication: 1. **Credential Protection**: User passwords remain client-side and are never transmitted across the network, even in hashed form. 2. **Replay Attack Resistance**: The combination of server nonces and nonce counts creates unique authentication tokens for each request, rendering captured authentication data unusable for subsequent unauthorized requests. 3. **Request Integrity**: By incorporating the HTTP method and URI into the digest calculation, the protocol prevents attackers from redirecting valid authentication tokens to different endpoints or modifying request methods. 4. **Mutual Authentication**: The client can verify server authenticity through the server's ability to validate client-generated digests, while the server confirms client identity through successful digest verification. 5. **Message Integrity Protection**: When utilizing `qop="auth-int"`, the protocol includes the request body hash in the authentication calculation, ensuring both authentication and message integrity. Despite these security enhancements, HTTPS deployment remains essential since Digest Authentication does not encrypt the communication channel. HTTPS is mandatory for production systems to provide comprehensive security through transport-layer encryption. Now that we have received the digest challenge, let's compute the digest response using the provided parameters: ``` HA1 = H(username:realm:password) = MD5(john:User Profile:password123) = e858d07c1afb2c75ea1f1ee29c1d7702 HA2 = H(method:uri) = MD5(GET:/profile/me) = 509ae9f341ffefdd68447afcdae1e7bf nonce = MTc1MzM0MjA2MDI0NDpmbXNGK2dTblF4WEVwN1gwWktMVllRPT0= // server-generated nonce nc = 00000001 // nonce count (hexadecimal) qop = auth cnonce = 71n315lg67i4kr9473e5hw // client-generated nonce response = H(HA1:nonce:nc:cnonce:qop:HA2) = MD5(e858d07c1afb2c75ea1f1ee29c1d7702:MTc1MzM0MjA2MDI0NDpmbXNGK2dTblF4WEVwN1gwWktMVllRPT0:00000001:71n315lg67i4kr9473e5hw:auth:509ae9f341ffefdd68447afcdae1e7bf) = f7e07fe43aa7a7e3a296edf8f3b3772a ``` After calculating the digest response, the client constructs the `Authorization` header with the computed authentication response: ```http GET /profile/me HTTP/1.1 Host: localhost:8080 Authorization: Digest username="john", realm="User Profile", nonce="MTc1MzM0MjA2MDI0NDpmbXNGK2dTblF4WEVwN1gwWktMVllRPT0=", uri="/profile/me", algorithm="MD5", qop="auth", nc="00000001", cnonce="71n315lg67i4kr9473e5hw", response="f7e07fe43aa7a7e3a296edf8f3b3772a", userhash=false, opaque="uSla+F7cMBsB/t3K9OCLzg==" ``` 4. **Server Authentication Verification**: The server validates the digest by recalculating it using identical parameters and stored credentials for user "john". Upon successful digest verification, the server grants access to the requested resource. The server extracts the "username" parameter from the authorization header, retrieves the user's password from its credential store, and computes the expected digest using the same algorithm and parameters as the client. When the computed digest matches the client-provided digest, the server responds with the requested resource: ```http HTTP/1.1 200 OK content-length: 76 content-type: text/plain date: Wed, 23 Jul 2025 12:59:32 GMT Hello john! This is your profile: Username: john Email: john@example.com ``` If digest verification fails, the server responds with `401 Unauthorized`, indicating authentication failure and presenting a new challenge in the `WWW-Authenticate` header. This was the simple digest authentication flow. In practice, the implementation may involve additional complexities such as nonce expiration, nonce reuse prevention, and handling of quality of protection levels. We will discuss these in the implementation section. ## Digest Authentication Implementation To implement Digest Authentication in ZIO HTTP, we develop middleware that intercepts incoming requests, validates `Authorization` headers, and authenticates digest credentials against stored user data. ZIO HTTP does not provide built-in Digest Authentication support but offers an excellent foundation for implementing it as custom middleware. ### Setting Up Dependencies First, add the necessary dependencies to your `build.sbt`: ```scala libraryDependencies ++= Seq( "dev.zio" %% "zio" % "2.1.26" "dev.zio" %% "zio-http" % "3.8.1", "dev.zio" %% "zio-schema" % "1.8.5", ) ``` ### Implementation Overview The middleware implementation handles two primary authentication scenarios based on `Authorization` header presence: - **Digest Authentication Present**: When the request contains a `Header.Authorization.Digest`, the client is responding to a server challenge. The middleware validates the digest against stored user credentials. Valid digests permit request continuation; invalid digests trigger a `401 Unauthorized` response with a new challenge in the `WWW-Authenticate` header. - **Missing Authentication**: When no authorization header exists or an unsupported authentication header is received, the middleware responds with `401 Unauthorized` status and a `WWW-Authenticate` header containing a new challenge. So, the general structure of the middleware is as follows: ```scala val digestAuthHandler: HandlerAspect[Any, Unit] = HandlerAspect.interceptIncomingHandler[Any, Unit] { Handler.fromFunctionZIO[Request](request => request.header(Header.Authorization) match { // Process Digest Authorization header case Some(authHeader: Header.Authorization.Digest) => // 1. Retrieve user credentials from credential store using header username // 2. Validate digest against stored user credentials // 3. On success, allow request continuation // 4. On failure, respond with 401 Unauthorized and new challenge // No authentication header present or unsupported authentication header, issue challenge case _ => // Respond with 401 Unauthorized and authentication challenge }, ) } ``` ### Digest Authentication Service Interface The implementation requires two core operations: - **Challenge Generation**: When clients attempt to access protected resources without authentication, the system generates challenges containing `realm`, `nonce`, `algorithm`, and quality of protection (`qop`) parameters. The server generates and transmits the challenge via the `WWW-Authenticate` header in the response. - **Response Validation**: When clients provide digest responses in `Authorization` headers, the server validates them against stored user credentials by computing expected digests and comparing them with client-provided values. These functionalities are encapsulated within a dedicated `DigestAuthService`: ```scala trait DigestAuthService { def generateChallenge( realm: String, qop: List[QualityOfProtection], algorithm: HashAlgorithm ): UIO[DigestChallenge] def validateResponse( digest: DigestResponse, password: Secret, method: Method, body: Option[String] = None, ): ZIO[Any, DigestAuthError, Boolean] } ``` The `DigestChallenge` data structure encapsulates challenge generation parameters including `realm`, `nonce`, `opaque`, `algorithm`, and `qop`: ```scala case class DigestChallenge( realm: String, nonce: String, opaque: Option[String] = None, algorithm: DigestAlgorithm = MD5, qop: List[QualityOfProtection] = List(Auth), stale: Boolean = false, domain: Option[List[String]] = None, charset: Option[String] = Some("UTF-8"), userhash: Boolean = false, ) { def toHeader: Header.WWWAuthenticate.Digest = ??? } object DigestChallenge { def fromHeader(header: Header.WWWAuthenticate.Digest): ZIO[Any, Nothing, DigestChallenge] = ??? } ``` The `DigestChallenge#toHeader` method transforms the `DigestChallenge` into a `Header.WWWAuthenticate.Digest` for HTTP response integration. The `DigestChallenge.fromHeader` method provides a type-safe conversion from `Header.WWWAuthenticate.Digest` header to `DigestChallenge`. The `DigestResponse` data structure represents client responses within `Authorization` headers: ```scala case class DigestResponse( response: String, username: String, realm: String, uri: URI, opaque: String, algorithm: DigestAlgorithm, qop: QualityOfProtection, cnonce: String, nonce: String, nc: NC, userhash: Boolean, ) object DigestResponse { def fromHeader(digest: Header.Authorization.Digest): DigestResponse = ??? } ``` The `DigestResponse.fromHeader` constructor provides type-safe conversion from `Header.Authorization.Digest` to `DigestResponse`. The `DigestAuthError` sealed trait represents authentication process errors: ```scala sealed trait DigestAuthError extends Throwable object DigestAuthError { case class InvalidResponse(expected: String, actual: String) extends DigestAuthError case class UnsupportedQop(qop: String) extends DigestAuthError case class MissingRequiredField(field: String) extends DigestAuthError case class UnsupportedAuthHeader(message: String) extends DigestAuthError } ``` The `DigestAlgorithm` enumeration represents cryptographic hash algorithms used in Digest Authentication, including standard algorithms like MD5, SHA-256, and SHA-512, along with their session variants: ```scala sealed abstract class DigestAlgorithm(val name: String, val digestSize: Int) { override def toString: String = name } object DigestAlgorithm { case object MD5 extends DigestAlgorithm("MD5", 128) case object MD5_SESS extends DigestAlgorithm("MD5-sess", 128) case object SHA256 extends DigestAlgorithm("SHA-256", 256) case object SHA256_SESS extends DigestAlgorithm("SHA-256-sess", 256) case object SHA512 extends DigestAlgorithm("SHA-512", 512) case object SHA512_SESS extends DigestAlgorithm("SHA-512-sess", 512) val values: List[DigestAlgorithm] = List(MD5, MD5_SESS, SHA256, SHA256_SESS, SHA512, SHA512_SESS) def fromString(s: String): Option[DigestAlgorithm] = values.find(_.name.equalsIgnoreCase(s.trim)) } ``` The `QualityOfProtection` sealed trait represents protection levels in Digest Authentication, encompassing `auth` for authentication-only and `auth-int` for authentication with message integrity protection: ```scala sealed abstract class QualityOfProtection(val name: String) { override def toString: String = name } object QualityOfProtection { case object Auth extends QualityOfProtection("auth") case object AuthInt extends QualityOfProtection("auth-int") private val values: Set[QualityOfProtection] = Set(Auth, AuthInt) def fromString(s: String): Option[QualityOfProtection] = values.find(_.name.equalsIgnoreCase(s.trim)) def fromChallenge(s: String): Set[QualityOfProtection] = s.split(",") .map(_.trim) .flatMap(QualityOfProtection.fromString) .toSet def fromChallenge(s: Option[String]): Set[QualityOfProtection] = s.fold(Set.empty[QualityOfProtection])(fromChallenge) } ``` Before diving into the implementation details of the `DigestAuthService`, we need to implement two supporting services: nonce management and digest computation. These services will handle nonce generation, validation, and digest response computation based on the challenge parameters. ### Nonce Management Service Challenge generation requires robust `nonce` generation. The `nonce` serves as a unique server-generated value for each challenge, preventing replay attacks and ensuring authentication freshness. It must be a cryptographically secure random value that changes for each request/session, rendering replay of previous requests impossible. While no specific algorithm is mandated for nonce generation, several industry-standard approaches exist: - **Cryptographically Random Nonce**: Generate nonces using cryptographically secure random number generators. This approach requires the server to maintain used nonce tracking to prevent replay attacks. After successful digest validation, the server marks the nonce as consumed, preventing reuse in subsequent requests. This approach typically does not utilize nonce count (`nc`), as each nonce is unique per session and cannot be reused: ```scala val nonce = Random.nextBytes(16) .map(_.toArray) .map(Base64.getEncoder.encodeToString) // Example nonce: pY0+z+EeTgrXwq/Y3L8lGA== ``` - **Temporal Nonce**: Generate nonces combining current timestamps with random values or timestamp hashes. This enables servers to validate nonce freshness and reject expired nonces, reducing replay attack windows. After successful digest validation, the server verifies nonce validity (non-expired status) and marks the `nonce` and `nc` combination as used, preventing reuse in subsequent requests. Subsequent requests with identical nonces must increment the `nc` value to indicate new requests. This approach enables nonce reuse for multiple requests until session expiration. This implementation utilizes the temporal nonce generation approach, providing superior nonce expiration policy control. We implement a `NonceService` to handle nonce generation, validation, and usage tracking: ```scala sealed trait NonceError extends Throwable object NonceError { case class NonceExpired(nonce: String) extends NonceError case class NonceAlreadyUsed(nonce: String, nc: NC) extends NonceError case class InvalidNonce(nonce: String) extends NonceError case class NonceOutOfSequence(nonce: String, nc: NC) extends NonceError } trait NonceService { def generateNonce: UIO[String] def validateNonce(nonce: String, maxAge: Duration): ZIO[Any, NonceError, Unit] def isNonceUsed(nonce: String, nc: NC): ZIO[Any, NonceError, Unit] def markNonceUsed(nonce: String, nc: NC): UIO[Unit] } ``` The `NC` class represents the nonce count (`nc`) as an 8-digit hexadecimal string with zero-padding: ```scala case class NC(value: Int) extends AnyVal { override def toString: String = toHexString private def toHexString: String = f"$value%08x" } object NC { implicit val ordering: Ordering[NC] = Ordering.by(_.value) } ``` Implementation of the `generateNonce` method: ```scala final case class NonceServiceLive( secretKey: Secret ) extends NonceService { private val HASH_ALGORITHM = "HmacSHA256" private val HASH_LENGTH = 16 def generateNonce: UIO[String] = Clock.currentTime(TimeUnit.MILLISECONDS).map { timestamp => val hash = Base64.getEncoder.encodeToString(createHash(timestamp)) val content = s"$timestamp:$hash" Base64.getEncoder.encodeToString(content.getBytes("UTF-8")) } def validateNonce(nonce: String, maxAge: Duration): UIO[Boolean] = ??? def isNonceUsed(nonce: String, nc: NC): UIO[Boolean] = ??? def markNonceUsed(nonce: String, nc: NC): UIO[Unit] = ??? private def createHash(timestamp: Long): Array[Byte] = { val mac = Mac.getInstance(HASH_ALGORITHM) mac.init(new SecretKeySpec(secretKey.stringValue.getBytes("UTF-8"), HASH_ALGORITHM)) mac.doFinal(timestamp.toString.getBytes("UTF-8")).take(HASH_LENGTH) } } ``` This nonce generation mechanism in digest authentication is designed to create cryptographically secure, self-verifying, time-bounded tokens. It uses HMAC (Hash-based Message Authentication Code) rather than plain SHA256. This is crucial because only the server with the `secretKey` can generate valid hashes, and no other party can forge valid nonces without access to this key. Another interesting aspect of this nonce generation is that it combines a timestamp with a hash of the timestamp, ensuring that each nonce is unique and time-sensitive: ``` nonce = Base64(timestamp:Base64(HMAC(timestamp))) ``` Each nonce is tied to a specific timestamp, which is the current time in milliseconds. This enables the server to extract the timestamp of received nonces to check the age of the nonce and determine if it is still valid. So, upon receiving client nonces, servers must perform two checks: - **Temporal Validation**: Verify timestamps fall within acceptable ranges (e.g., not exceeding 5 minutes age) to prevent replay attack utilization of expired requests. - **Cryptographic Verification**: Confirm hash values match computed hashes for given timestamps using identical secret keys. This ensures the server generated the nonce and prevents tampering. If the nonce passes both checks, it is considered valid. Let's implement the `validateNonce` method: ```scala final case class NonceServiceLive(secretKey: Secret) extends NonceService { private def computeHash(timestamp: Long, secretKey: Secret): Array[Byte] = ??? def generateNonce: UIO[String] = ??? def validateNonce(nonce: String, maxAge: Duration): ZIO[Any, NonceError, Unit] = ZIO.fromEither { try { val decoded = new String(Base64.getDecoder.decode(nonce), "UTF-8") val parts = decoded.split(":", 2) if (parts.length != 2) { Left(NonceError.InvalidNonce(nonce)) } else { val timestamp = parts(0).toLong val providedHash = Base64.getDecoder.decode(parts(1)) val isWithinTimeLimit = java.lang.System.currentTimeMillis() - timestamp <= maxAge.toMillis if (!isWithinTimeLimit) { Left(NonceError.NonceExpired(nonce)) } else if (!constantTimeEquals(createHash(timestamp), providedHash)) { Left(NonceError.InvalidNonce(nonce)) } else { Right(()) } } } catch { case _: Exception => Left(NonceError.InvalidNonce(nonce)) } } def isNonceUsed(nonce: String, nc: NC): UIO[Boolean] = ??? def markNonceUsed(nonce: String, nc: NC): UIO[Unit] = ??? private def constantTimeEquals(a: Array[Byte], b: Array[Byte]): Boolean = a.length == b.length && a.zip(b).map { case (x, y) => x ^ y }.fold(0)(_ | _) == 0 } ``` After nonce validation, the system must verify that the `nonce` and `nc` combination has not been previously used. This is accomplished by maintaining a registry of used nonces with their associated counts (`nc`). Discovery of nonces within this registry indicates prior usage, warranting request rejection. This mechanism prevents intra-session replay attacks where attackers might attempt to reuse valid nonces from previous requests before expiration. To implement this method, called `isNonceUsed`, we can naively use a `Ref` to store the used nonces in memory, which maintains a map of nonces to sets of counts (`nc`) that have been used. But a better approach is to use a `Ref` to store a map of nonces to their last used `nc` values. This allows us to check if the current `nc` is greater than or equal to the last used `nc` for that nonce, which indicates that the nonce has already been used in a previous request or is out of sequence: ```scala final case class NonceServiceLive( usedNonces: Ref[Map[String, NC]], secretKey: SecretKey ) extends NonceService { def generateNonce: UIO[String] = ??? def validateNonce(nonce: String, maxAge: Duration): UIO[Boolean] = ??? def isNonceUsed(nonce: String, nc: NC): ZIO[Any, NonceError, Unit] = for { usedNoncesMap <- usedNonces.get _ <- usedNoncesMap.get(nonce) match { case Some(lastUsedNc) if nc <= lastUsedNc => ZIO.fail(NonceAlreadyUsed(nonce, nc)) case Some(lastUsedNc) if nc.value != lastUsedNc.value + 1 => ZIO.fail(NonceOutOfSequence(nonce, nc)) case _ => ZIO.unit } } yield () def markNonceUsed(nonce: String, nc: NC): UIO[Unit] = ??? } ``` Similarly, we need to implement the `markNonceUsed` method to mark a `nonce` and `nc` as used after a successful authentication: ```scala final case class NonceServiceLive( usedNonces: Ref[Map[String, NC]], secretKey: SecretKey ) extends NonceService { def generateNonce: UIO[String] = ??? def validateNonce(nonce: String, maxAge: Duration): UIO[Boolean] = ??? def isNonceUsed(nonce: String, nc: NC): ZIO[Any, NonceError, Unit] = ??? def markNonceUsed(nonce: String, nc: NC): UIO[Unit] = usedNonces.update { nonces => val currentMax = nonces.getOrElse(nonce, NC(0)) nonces.updated(nonce, currentMax max nc) } } ``` The nonce generation and validation logic is now encapsulated within the `NonceService`, which can be injected into the `DigestAuthService` for nonce management during digest authentication processes. The next implementation step involves digest response computation. ### Computation of Digest Response To compute the digest response, we need to implement a service that calculates the digest response based on the provided parameters such as `username`, `realm`, `password`, `nonce`, `nc`, `cnonce`, `algorithm`, `qop`, `uri`, and `method`. Here is a simple interface for the digest computation service: ```scala trait DigestService { def calculateResponse( username: String, realm: String, password: Secret, nonce: String, nc: NC, cnonce: String, algorithm: DigestAlgorithm, qop: QualityOfProtection, uri: URI, method: Method, body: Option[String] = None, ): UIO[String] } ``` As discussed earlier, the digest response is calculated using the following formula: ``` HA1 = H(username:realm:password) if (qop == "auth-int") HA2 = H(method:uri:body) else HA2 = H(method:uri) response = H(HA1:nonce:nc:cnonce:qop:HA2) ``` However, this is the simple version of the digest calculation when the algorithm is a regular algorithm, such as `MD5` or `SHA-256`. If the algorithm is a session algorithm (ending with "-sess", such as `MD5-sess` or `SHA-256-sess`), the `HA1` is calculated differently: ``` If algorithm ends in "-sess": HA1 = H(H(username:realm:password):nonce:cnonce) otherwise: HA1 = H(username:realm:password) if (qop == "auth-int") HA2 = H(method:uri:body) else HA2 = H(method:uri) response = H(HA1:nonce:nc:cnonce:qop:HA2) ``` In regular algorithms, the `HA1` is a constant value for a given `username`, `realm`, and `password`. This means that if an attacker captures the `HA1` value, they can use it to generate valid digests for any request made by that user. Session algorithms are variants of digest algorithms that enhance security by making the hash depend not only on the `username`, `realm`, and `password` but also on values that are unique to each session, such as the server nonce (`nonce`) and the client nonce (`cnonce`). Even if an attacker obtains `H(username:realm:password)` through network monitoring, sniffing, or memory dumps, they cannot generate valid digests without knowing the specific `nonce` and `cnonce` values. As a security best practice, always prefer `-sess` algorithms when both client and server support them, particularly for unsecured network communications. Now, let's implement the `computeResponse` method in the `DigestService`: ```scala def computeResponse( username: String, realm: String, password: Secret, nonce: String, nc: NC, cnonce: String, algorithm: DigestAlgorithm, qop: QualityOfProtection, uri: URI, method: Method, body: Option[String] = None, ): UIO[String] = for { a1 <- computeA1(username, realm, password, nonce, cnonce, algorithm) ha1 <- hash(a1, algorithm) a2 <- computeA2(method, uri, algorithm, qop, body) ha2 <- hash(a2, algorithm) response <- computeFinalResponse(ha1, ha2, nonce, nc, cnonce, qop, algorithm) } yield response ``` The calculation of `a1` is straightforward. Based on the type of algorithm, we can use either the simple formula or the session formula: ```scala private def computeA1( username: String, realm: String, password: Secret, nonce: String, cnonce: String, algorithm: DigestAlgorithm, ): UIO[String] = { val baseA1 = s"$username:$realm:${password.stringValue}" algorithm match { case MD5_SESS | SHA256_SESS | SHA512_SESS => hash(baseA1, algorithm) .map(ha1 => s"$ha1:$nonce:$cnonce") case _ => ZIO.succeed(baseA1) } } ``` The `a2` computation encompasses HTTP method and URI hash calculation. For `auth-int` quality of protection, the request body is included in the calculation: ```scala private def computeA2( method: Method, uri: URI, algorithm: DigestAlgorithm, qop: QualityOfProtection, entityBody: Option[String], ): UIO[String] = { qop match { case QualityOfProtection.AuthInt => entityBody match { case Some(body) => hash(body, algorithm) .map(hbody => s"${method.name}:${uri.getPath}:$hbody") case None => ZIO.succeed(s"${method.name}:${uri.getPath}:") } case _ => ZIO.succeed(s"${method.name}:${uri.getPath}") } } ``` Here is the final response generation: ```scala private def computeFinalResponse( ha1: String, ha2: String, nonce: String, nc: NC, cnonce: String, qop: QualityOfProtection, algorithm: DigestAlgorithm, ): UIO[String] = hash(s"$ha1:$nonce:$nc:$cnonce:${qop.name}:$ha2", algorithm) ``` The `hash` function computes cryptographic hashes using specified algorithms, implemented with Java's `MessageDigest` or equivalent libraries: ```scala private def hash(data: String, algorithm: DigestAlgorithm): UIO[String] = ZIO.succeed { val md = algorithm match { case MD5 | MD5_SESS => MessageDigest.getInstance("MD5") case SHA256 | SHA256_SESS => MessageDigest.getInstance("SHA-256") case SHA512 | SHA512_SESS => MessageDigest.getInstance("SHA-512") } md.digest(data.getBytes("UTF-8")) .map(b => String.format("%02x", b & 0xff)) .mkString } ``` ### Digest Authentication Service Implementation Now that all required services are implemented, we are ready to implement the `DigestAuthService` that uses nonce management and digest computation services to handle authentication challenges and response validation. We have to implement two main methods in the `DigestAuthService`: 1. `generateChallenge`: Generates a digest challenge with parameters like `realm`, `nonce`, `qop`, and `algorithm`. 2. `validateResponse`: Validates client-provided digest responses against stored user credentials, ensuring nonce freshness and preventing replay attacks. Let's implement the `generateChallenge` method in the `DigestAuthService`: ```scala case class DigestAuthServiceLive( nonceService: NonceService, ) extends DigestAuthService { def generateChallenge( realm: String, qop: List[QualityOfProtection], algorithm: HashAlgorithm, ): UIO[DigestChallenge] = for { nonce <- nonceService.generateNonce opaque <- generateOpaque } yield DigestChallenge( realm = realm, nonce = nonce, opaque = Some(opaque), algorithm = algorithm, qop = qop, ) private def generateOpaque: UIO[String] = Random .nextBytes(OPAQUE_BYTES_LENGTH) .map(_.toArray) .map(Base64.getEncoder.encodeToString) } ``` The `opaque` parameter is a server-selected optional value that clients must return unchanged within `Authorization` headers when responding to challenges. Here is the `validateResponse` method implementation, which validates the client's digest response by comparing it with server-calculated expected values: ```scala case class DigestAuthServiceLive( nonceService: NonceService, digestService: DigestService, ) extends DigestAuthService { def generateChallenge( realm: String, qop: List[QualityOfProtection], algorithm: HashAlgorithm, ): UIO[DigestChallenge] = ??? def validateResponse( response: DigestResponse, password: Secret, method: Method, supportedQop: Set[QualityOfProtection], body: Option[String] = None, ): ZIO[Any, DigestAuthError, Unit] = { val r = response def mapNonceError: NonceError => InvalidResponse = _ => InvalidResponse(r.response) for { _ <- ZIO.when(!supportedQop.contains(r.qop))(ZIO.fail(UnsupportedQop(r.qop.name))) _ <- nonceService.validateNonce(r.nonce, Duration.fromSeconds(NONCE_MAX_AGE)).mapError(mapNonceError) _ <- nonceService.isNonceUsed(r.nonce, r.nc).mapError(mapNonceError) expected <- digestService.computeResponse(r.username, r.realm, password, r.nonce, r.nc, r.cnonce, r.algorithm, r.qop, r.uri, method, body) _ <- isEqual(expected, r.response) _ <- nonceService.markNonceUsed(r.nonce, r.nc) } yield () } } ``` Please note that in the `DigestAuthService` layer, we do not expose nonce errors directly to the client for security reasons. Instead, all nonce-related errors are mapped to `InvalidResponse`, a more generic error type. The validation process involves several steps: 1. **Nonce Validation**: Check if the nonce is valid and not expired using the `NonceService`. 2. **Replay Attack Prevention**: Ensure the `nonce` and `nc` combination has not been previously used. 3. **Calculate the Expected Response**: Compute the expected response digest using the provided parameters and the user's password. 4. **Compare Responses**: Compare the expected response with the one provided by the client. 5. **Mark Nonce as Used**: If the response is valid, mark the nonce as used to prevent replay attacks in future requests. Among these steps, the remaining implementation detail is the comparison of the expected response with the one provided by the client. This is done using a constant-time comparison to prevent timing attacks: ```scala private def isEqual(expected: String, actual: String): ZIO[Any, InvalidResponse, Unit] = { val exp = expected.getBytes("UTF-8") val act = actual.getBytes("UTF-8") if (MessageDigest.isEqual(exp, act)) ZIO.unit else ZIO.fail(InvalidResponse(expected, actual)) } ``` We used `MessageDigest.isEqual()`, which is a secure, constant-time comparison method provided by Java's cryptography APIs specifically designed for comparing sensitive cryptographic values. ## User Management Service Besides the `DigestAuthService`, we also need a `UserService` to store and retrieve user credentials: ```scala case class User(username: String, password: Secret, email: String) sealed trait UserServiceError object UserServiceError { case class UserNotFound(username: String) extends UserServiceError case class UserAlreadyExists(username: String) extends UserServiceError } trait UserService { def getUser(username: String): IO[UserServiceError, User] def addUser(user: User): IO[UserServiceError, Unit] def updateEmail(username: String, newEmail: String): IO[UserServiceError, Unit] } ``` For the sake of simplicity, we can use an in-memory store for users, but in a real application, you would typically use a database or another persistent storage solution: ```scala case class UserServiceLive(users: Ref[Map[String, User]]) extends UserService { def getUser(username: String): IO[UserServiceError, User] = users.get.flatMap { userMap => ZIO.fromOption(userMap.get(username)) .orElseFail(UserNotFound(username)) } def addUser(user: User): IO[UserServiceError, Unit] = users.get.flatMap { userMap => ZIO.when(userMap.contains(user.username)) { ZIO.fail(UserAlreadyExists(user.username)) } *> users.update(_.updated(user.username, user)) } def updateEmail(username: String, newEmail: String): IO[UserServiceError, Unit] = for { currentUsers <- users.get user <- ZIO.fromOption(currentUsers.get(username)).orElseFail(UserNotFound(username)) _ <- users.update(_.updated(username, user.copy(email = newEmail))) } yield () } object UserService { private val initialUsers = Map( "john" -> User("john", Secret("password123"), "john@example.com"), "jane" -> User("jane", Secret("secret456"), "jane@example.com"), "admin" -> User("admin", Secret("admin123"), "admin@company.com"), ) val live: ZLayer[Any, Nothing, UserService] = ZLayer.fromZIO(Ref.make(initialUsers).map(UserServiceLive(_))) } ``` We initialize the `UserService` with some sample users, which can be used for testing purposes. The `getUser` method retrieves a user by username, while `addUser` allows adding new users, and `updateEmail` updates the user's email address. ## Middleware Implementation The middleware uses the `UserService` to retrieve user credentials in order to validate the digest. We can make our middleware more flexible by passing the user details to the outgoing request context in case of successful validation, so that downstream handlers can access the authenticated user information: ```scala object DigestAuthHandlerAspect { def apply( realm: String, qop: Set[QualityOfProtection] = Set(Auth), supportedAlgorithms: Set[DigestAlgorithm] = Set(MD5, MD5_SESS, SHA256, SHA256_SESS, SHA512, SHA512_SESS), ): HandlerAspect[DigestAuthService & UserService, User] = { def unauthorizedResponse(message: String): ZIO[DigestAuthService, Response, Nothing] = ZIO .collectAll( supportedAlgorithms .map(algorithm => ZIO.serviceWithZIO[DigestAuthService](_.generateChallenge(realm, qop, algorithm))), ) .flatMap(challenges => ZIO.fail( Response .unauthorized(message) .addHeaders(Headers(challenges.map(_.toHeader))), ), ) HandlerAspect.interceptIncomingHandler[DigestAuthService & UserService, User] { handler { (request: Request) => request.header(Header.Authorization) match { case Some(digest: Header.Authorization.Digest) => { for { user <- ZIO .serviceWithZIO[UserService](_.getUser(digest.username)) body <- request.body.asString.option _ <- ZIO .serviceWithZIO[DigestAuthService]( _.validateResponse(DigestResponse.fromHeader(digest), user.password, request.method, qop, body), ) } yield (request, user) }.catchAll(_ => unauthorizedResponse("Authentication failed!")) case _ => unauthorizedResponse(s"Missing Authorization header for realm: $realm") } } } } } ``` This middleware accepts three configuration parameters: - **`realm`**: The authentication realm string identifying the protected resource space - **`qop`**: A set of `QualityOfProtection` values that specify the quality of protection to use, such as `auth`, `auth-int`, or both. The challenge generated by the middleware will include these values in the `qop` parameter. For example, if `qop` is set to `Set(Auth, AuthInt)`, the challenge will include both `auth` and `auth-int` in the `qop` parameter, allowing the client to choose which one to use. - **`supportedAlgorithms`**: A set of `DigestAlgorithm` values that specify the supported hashing algorithms for digest authentication. The middleware will generate challenges for each of the supported algorithms, allowing the client to choose which one to use. For example, if `supportedAlgorithms` is set to `Set(MD5, SHA256)`, the challenge will include both `MD5` and `SHA256` headers, allowing the client to pick one of them. ## Middleware Application Now we can finally use this middleware in our ZIO HTTP application to protect our `/profile/me` route: ```scala val profileRoute: Route[DigestAuthService & UserService, Nothing] = Method.GET / "profile" / "me" -> handler { (_: Request) => for { user <- ZIO.service[User] } yield Response.text( s"Hello ${user.username}! This is your profile: \n Username: ${user.username} \n Email: ${user.email}", ) } @@ DigestAuthHandlerAspect(realm = "User Profile") ``` Applying this middleware to our route requires two services in the environment: `DigestAuthService` and `UserService`. The `DigestAuthService` is responsible for generating challenges and validating responses, while the `UserService` provides user credentials for authentication. The beautiful thing about this middleware is that it extracts the authenticated user from the request and makes it available to downstream handlers. This allows us to access the user information in the handler using the ZIO environment (`ZIO.service[User]`) without having to pass it explicitly. Let's write another route for updating the user's email, which should also be protected by the digest authentication middleware. The difference is that this route will require the user to provide a new email address in the request body to update their profile: ``` PUT /profile/me/email HTTP/1.1 Host: localhost:8080 Authorization: Digest ... Content-Type: application/json Content-Length: 42 { "email": "my-new-email@example.com" } ``` Since this route has a request body, we need to ensure that the `qop` is set to `auth-int`, which means that the request body will be included in the digest calculation. This way, the server can verify the integrity of the request body and ensure that it has not been tampered with: ```scala val updateEmailRoute: Route[DigestAuthService & UserService, Nothing] = Method.PUT / "profile" / "email" -> Handler.fromZIO(ZIO.service[UserService]).flatMap { userService => handler { (req: Request) => for { user <- ZIO.service[User] updateRequest <- req.body .to[UpdateEmailRequest] .mapError(error => Response.badRequest(s"Invalid JSON (UpdateEmailRequest): $error")) _ <- userService .updateEmail(user.username, updateRequest.email) .logError(s"Failed to update email for user ${user.username}") .mapError(_ => Response.internalServerError(s"Failed to update email!")) } yield Response.text( s"Email updated successfully for user ${user.username}! New email: ${updateRequest.email}", ) } @@ DigestAuthHandlerAspect(realm = "User Profile", qop = Set(AuthInt)) } ``` Here are some points to consider when writing this route: First, we use `Set(AuthInt)` as the Quality of Protection (`qop`) parameter. This requires the client to include the request body in the digest calculation, ensuring that the integrity of the request body is verified by the server. Please note that we can also include the `Auth` value in the `qop` list, which allows the client to optionally choose either `auth` or `auth-int` for the authentication request. Second, in the implementation of the route, we extract the user details from the ZIO environment using the `ZIO.service[User]` method, which is made available by the `DigestAuthHandlerAspect`. Besides this, we also need to obtain the `UserService` from the ZIO environment to update the user's email. If we do both of these inside one handler, the type of our handler becomes `Handler[User & UserService, Response, Request, Response]`. On the other hand, the type of the `DigestAuthHandlerAspect` is `HandlerAspect[DigestAuthService & UserService, User]`. The type of the handler aspect tells us that it can only be applied to a handler whose environment is a subset of `User`. Therefore, it can't be applied to a handler that requires both `User` and `UserService` in its environment. To solve this, we chain two handlers using `flatMap`, where the first handler extracts the `UserService` from the environment, and the second handler uses it to update the user's email. The inner handler has the type `Handler[User, Response, Request, Response]`, which is compatible with the `DigestAuthHandlerAspect` that requires only `User` in its environment. Now we can apply the `DigestAuthHandlerAspect` to the inner handler and chain it with the outer handler that extracts the `UserService` from the environment. ## Client Implementation for Testing Digest Authentication As we saw, the digest authentication process involves at least two requests: the first request to get the challenge and the second request to send the response (response to the challenge). However, this is not always the case. If the client already has a valid `nonce`, it can use it for subsequent requests without needing to get a new challenge until the nonce expires. To create a client that reuses `nonce` between calls, we need a client that manages state. First, let's define the interface of the client: ```scala trait DigestAuthClient { def makeRequest(request: Request): ZIO[Any, DigestAuthError, Response] } ``` We need the `Client`, `DigestService`, and `NonceService` to implement the `DigestAuthClient`. We also need to maintain two states: - one for the digest challenge parameters (`Ref[Option[DigestChallenge]]`). We made it optional because the first time the client makes a request, it won't have any cached digest challenge parameters. - another for maintaining the nonce count (`Ref[NC]`). To simplify the implementation, we pass the username and password when constructing the client. The client will use these credentials to compute the digest response when making requests: ```scala final case class DigestAuthClientImpl( challengeRef: Ref[Option[DigestChallenge]], ncRef: Ref[NC], client: Client, digestService: DigestService, nonceService: NonceService, username: String, password: Secret, ) extends DigestAuthClient { override def makeRequest(request: Request): ZIO[Any, DigestAuthError, Response] = for { authenticatedRequest <- authenticate(request) response <- client.batched(authenticatedRequest).orDie finalResponse <- if (response.status == Status.Unauthorized) { for { _ <- ZIO.debug("Unauthorized response received!") _ <- handleUnauthorized(response) retryRequest <- authenticate(request) retryResponse <- client.batched(retryRequest).orDie _ <- ZIO.debug("Retrying request with updated authentication headers") } yield retryResponse } else ZIO.succeed(response) } yield finalResponse } ``` The only method we need to implement is `makeRequest`, which takes a `Request`, performs an authenticated request, and returns the `Response`. This method implements the core digest authentication flow by handling both initial authentication challenges and request retries. The method first attempts to authenticate the incoming request using any cached digest challenge parameters and then sends it to the server. If the response returns successfully (non-401 status), it's returned immediately. However, if the server responds with **401 Unauthorized**, the method enters the standard digest authentication challenge-response flow: it parses the WWW-Authenticate header to extract and cache the new digest challenge parameters, then computes the response and re-authenticates the original request with a response digest. Here is the implementation of the `authenticate` helper method. It takes a `Request`, uses the cached digest to compute the response, and adds the proper digest header to the given request: ```scala def authenticate(request: Request): ZIO[Any, Nothing, Request] = challengeRef.get.flatMap { case None => ZIO.debug(s"No cached digest!") *> ZIO.debug("Sending request without auth header to get a fresh challenge") *> ZIO.succeed(request) case Some(challenge) => for { _ <- ZIO.debug(s"Cached digest challenge found, using it to compute the digest response!") cnonce <- nonceService.generateNonce nc <- ncRef.updateAndGet(nc => NC(nc.value + 1)) selectedQop = selectQop(request, challenge.qop) _ <- ZIO.debug(s"Selected QOP: $selectedQop") uri = URI.create(request.url.path.toString) body <- request.body.asString.map(Some(_)).orDie response <- digestService.computeResponse( username = username, realm = challenge.realm, uri = uri, algorithm = challenge.algorithm, qop = selectedQop, cnonce = cnonce, nonce = challenge.nonce, nc = nc, password = password, method = request.method, body = body, ) authHeader = Header.Authorization.Digest( response = response, username = username, realm = challenge.realm, nonce = challenge.nonce, uri = uri, algorithm = challenge.algorithm.toString, qop = selectedQop.toString, nc = nc.value, cnonce = cnonce, userhash = false, opaque = challenge.opaque.getOrElse(""), ) _ <- ZIO.debug(s"nonce: ${challenge.nonce}") _ <- ZIO.debug(s"nc: $nc") _ <- ZIO.debug(s"response: $response") } yield request.addHeader(authHeader) } ``` We also need another helper method that chooses between two security levels based on request characteristics and server capabilities. It takes an HTTP request and a set of supported QoP values as parameters, then returns `AuthInt` (authentication with integrity protection) if the request has a non-empty body and the server supports it; otherwise, it defaults to `Auth` (authentication only): ```scala def selectQop(request: Request, supportedQop: Set[QualityOfProtection]): QualityOfProtection = if (!request.body.isEmpty && supportedQop.contains(QualityOfProtection.AuthInt)) QualityOfProtection.AuthInt else QualityOfProtection.Auth ``` To extract and store the digest challenge from unauthorized response calls, here is another helper method called `handleUnauthorized`: ```scala def handleUnauthorized(response: Response): ZIO[Any, DigestAuthError, Unit] = response.header(Header.WWWAuthenticate) match { case Some(header: Header.WWWAuthenticate.Digest) => for { _ <- ZIO.debug(s"Received a new WWW-Authenticate Digest challenge") newChallenge <- DigestChallenge.fromHeader(header) _ <- ZIO.debug(s"Caching digest challenge") _ <- challengeRef.set(Some(newChallenge)) _ <- ncRef.set(NC(0)) // Reset nonce count } yield () case _ => ZIO.fail(UnsupportedAuthHeader("Expected WWW-Authenticate header")) } ``` Storing the digest challenge parameters enables reusing the same challenge for subsequent requests until the nonce expires or the server sends a new challenge. When receiving a new challenge, we reset the nonce count (`nc`) to zero so the next request starts with `nc = 1`. This prevents sending out-of-order nonce counts that would cause the server to reject the request since the server tracks nonce count usage for each nonce it issues. Now, we are ready to write some API calls and see how digest authentication works in practice: ```scala val program: ZIO[Client with DigestAuthClient, Throwable, Unit] = for { authClient <- ZIO.service[DigestAuthClient] profileEndpoint <- ZIO.fromEither(URL.decode(s"$url/profile/me")) emailEndpoint <- ZIO.fromEither(URL.decode(s"$url/profile/email")) _ <- ZIO.debug("\nFirst call: GET /profile/me") response1 <- authClient.makeRequest(Request(method = Method.GET, url = profileEndpoint)) body1 <- response1.body.asString _ <- ZIO.debug(s"Received response: $body1") _ <- ZIO.debug("\nSecond call: GET /profile/me") response2 <- authClient.makeRequest(Request(method = Method.GET, url = profileEndpoint)) body2 <- response2.body.asString _ <- ZIO.debug(s"Received response: $body2") _ <- ZIO.debug("\nThird call: PUT /profile/email") email = UpdateEmailRequest("my-new-email@example.com") emailRequest = Request(method = Method.PUT, url = emailEndpoint, body = Body.from(email)) response3 <- authClient.makeRequest(emailRequest) body3 <- response3.body.asString _ <- ZIO.debug(s"Received response: $body3") _ <- ZIO.debug("\nFourth call: GET /profile/me") response4 <- authClient.makeRequest(Request(method = Method.GET, url = profileEndpoint)) body4 <- response4.body.asString _ <- ZIO.debug(s"Received response: $body4") } yield () ``` To run this program, you need to have the `DigestAuthClient` and `DigestService` in your ZIO environment: ```scala program.provide( Client.default, NonceService.live, DigestService.live, DigestAuthClient.live(USERNAME, PASSWORD), ) ``` This ZIO program executes four sequential HTTP calls: 1. `GET /profile/me` (initial request) 2. `GET /profile/me` (second request using cached authentication) 3. `PUT /profile/email` (update email address) 4. `GET /profile/me` (verify the email update) Let's discuss each call in detail: **First Call:** Since no digest challenge is cached, the client sends an unauthenticated request. The server responds with `401 Unauthorized` and includes a `WWW-Authenticate` header containing the digest challenge parameters (realm, nonce, algorithm, etc.). The client parses and caches this challenge information, computes the digest response using the user's credentials, and retries the request with the computed authentication header. This results in a successful response containing the user's profile. **Second Call:** The client reuses the cached digest challenge to compute the authentication response without requiring a server round-trip for challenge negotiation. It increments the nonce count (`nc`) to `00000002`, ensuring the server recognizes this as a distinct request and preventing replay attacks. The authentication succeeds immediately without an initial `401` response, demonstrating efficient authentication state reuse. **Third Call:** The `PUT /profile/email` request attempts to update the user's email address. The client computes the digest response using the cached challenge and increments the nonce count to `00000003`. However, since this endpoint requires `auth-int` (authentication with integrity protection) quality of protection to verify that the request body hasn't been tampered with, the server rejects this request with `401 Unauthorized` because the request's response header is computed using the wrong `qop` parameter, namely `auth`. The client receives a new digest challenge specifying `auth-int` QOP, caches this updated challenge, and retries the request. For the new challenge, the nonce count starts from `00000001`, and the client successfully authenticates using `auth-int`, which includes the hash of the request body in the digest calculation. **Fourth Call:** The final `GET /profile/me` request retrieves the updated profile information. The client uses the most recently cached challenge (from the PUT request), incrementing the nonce count to `00000002`. Since this is a GET request with an empty body, the client uses standard `auth` quality of protection rather than `auth-int`. The server responds with the updated profile, confirming that the email address change was successful. This design provides transparent handling of digest authentication, allowing clients to make requests normally without manually managing the challenge-response handshake. The method efficiently addresses both cold-start scenarios, where the first request triggers a challenge, and challenge-refresh situations, where server nonces expire, while maintaining authentication state between requests for optimal performance. ## Source Code The complete source code for this Digest Authentication example is available in the ZIO HTTP examples repository. To clone the example: ```bash git clone --depth 1 --filter=blob:none --sparse https://github.com/zio/zio-http.git cd zio-http git sparse-checkout set zio-http-example-digest-auth ``` ### Running the Server To run the authentication server: ```bash cd zio-http/zio-http-example-digest-auth sbt "runMain example.auth.digest.AuthenticationServer" ``` The server starts on `http://localhost:8080` with these test users: | Username | Password | Email | |----------|---------------|----------------------| | `john` | `password123` | john@example.com | | `jane` | `secret456` | jane@example.com | | `admin` | `admin123` | admin@company.com | ### ZIO HTTP Client Run the command-line client (ensure server is running): ```bash cd zio-http/zio-http-example-digest-auth sbt "runMain example.auth.digest.AuthenticationClient" ``` The client executes four sequential HTTP calls demonstrating the digest authentication flow. ### Web-Based Client Navigate to `http://localhost:8080` to access the interactive web interface. The web client allows you to: - Enter username and password (defaults to john/password123) - Test public endpoints (no authentication) - Test protected endpoints (profile, admin) - View console output showing the authentication flow - Observe automatic handling of digest challenges and responses The client automatically handles: - Initial unauthenticated requests - Parsing WWW-Authenticate challenges - Computing MD5 digest responses - Including Authorization headers in subsequent requests - Managing nonce counts for replay protection To use the simple version (useful for learning the basics), modify `AuthenticationServer.scala`: ```scala Method.GET / Root -> Handler .fromResource("digest-auth-client-simple.html") .orElse(Handler.internalServerError("Failed to load HTML file")) ``` **Note:** The web client uses `credentials: 'omit'` in fetch requests to prevent the browser's built-in authentication popup. Without this, when the server returns 401 with a WWW-Authenticate header, the browser automatically shows its native login dialog, bypassing the custom form. ## Demo We have deployed a live demo at: [https://digest-auth-demo.ziohttp.com/](https://digest-auth-demo.ziohttp.com/). It includes a section that demonstrates transaction details and displays the request and response headers for each API call. This can help you understand how Digest Authentication works under the hood. You can also use the browser’s developer tools to inspect network requests and responses, including the Authorization headers and nonce values. ## Conclusion In this guide, we demonstrated an implementation of Digest Authentication using ZIO HTTP, from understanding the underlying cryptographic protocols to building a nearly production-ready middleware and client implementations. Through our exploration, we've covered how to use the essential capabilities of ZIO and ZIO HTTP to create a secure, modular, and maintainable authentication system: 1. **ZIO HTTP Middleware/HandlerAspect** - We created a reusable authentication middleware that can be applied to any route, allowing for easy integration of Digest Authentication into existing applications. 2. **Mutable References (`Ref`)** - We utilized mutable references to manage stateful components like nonce tracking and cached challenges, demonstrating how ZIO's functional programming model can handle stateful operations safely and efficiently. 3. **ZIO Service Pattern** - We designed the entire authentication system using ZIO services, promoting separation of concerns and modularity. Each service (NonceService, DigestService, DigestAuthService, UserService) encapsulates specific functionality, making the codebase easier to maintain and test, applying the principles of object-oriented design to functional programming. Our implementation successfully addresses the core security requirements of modern web applications: **1. Password Protection**: By never transmitting passwords across the network—even in hashed form—our implementation ensures that user credentials remain secure. The challenge-response mechanism proves credential knowledge without exposing sensitive data. **2. Replay Attack Prevention**: Through the combination of temporal nonces with HMAC-based validation and incrementing nonce counts, we've built a robust defense against replay attacks. The stateful tracking of used nonce-count values ensures that captured authentication tokens cannot be reused maliciously. **3. Request Integrity**: By incorporating HTTP methods, URIs, and optionally request bodies into the digest calculation, our implementation prevents request tampering and ensures that authentication tokens cannot be reused for unintended endpoints. While Digest Authentication is not the most modern authentication mechanism, it remains relevant for legacy systems and specific use cases where stateless authentication is required. However, it is essential to understand its limitations and consider more modern alternatives for new projects, such as OAuth 2.0, OpenID Connect, or JSON Web Tokens (JWT) with refresh tokens, which we will explore in future guides. --- ## Implementing Mutual TLS (mTLS) ## Introduction Mutual TLS (mTLS) extends standard TLS by requiring both the client and server to present and verify certificates. This creates bidirectional authentication, ensuring both parties are who they claim to be. mTLS is crucial for zero-trust architectures, API security, and microservices communication. Until now, all the guides in these series were based on standard TLS. This article demonstrates implementing mTLS with practical examples in Scala using the ZIO HTTP library. ## Understanding Mutual TLS In standard TLS (One-way TLS) only the server presents a certificate to the client, allowing the client to verify the server's identity. The client remains anonymous, and there is no verification of the client's identity. However, in mutual TLS (mTLS), both the server and client present certificates to each other. This allows both parties to verify each other's identities, providing a higher level of security. Here is a diagram that illustrates the difference between standard TLS and mutual TLS: ``` Standard TLS: Mutual TLS: ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ Client │ │ Server │ │ Client │ │ Server │ └───┬────┘ └────┬───┘ └───┬────┘ └────┬───┘ │ Who are you? │ │ Who are you? │ │<───────────────┤ │<────────────────────┤ │ Certificate │ │ Certificate │ │ │ │ │ │ │ │ Who are YOU? │ │ │ ├────────────────────>│ │ │ │ Certificate │ │ │ │ │ └─ Authenticated ┘ └─ Both Authenticated ┘ ``` ## Creating Certificates for mTLS The certificate generation process involves creating a Certificate Authority (CA), signing server and client certificates, and creating keystores and truststores. This process is similar for both the server and client, with some differences in the certificate attributes. ### Step 1: Create Certificate Authority We need to create a Certificate Authority (CA) that will sign both the server and client certificates. This CA will be used to verify the authenticity of the certificates presented by the server and client during the TLS handshake. First, we will generate a private key for the CA: ```bash # Generate CA private key openssl genrsa -out ca-key.pem 4096 ``` Now we can create a self-signed certificate for the CA. ```bash # Generate CA certificate openssl req -new -x509 -days 3650 -key ca-key.pem -out ca-cert.pem -subj "/CN=MyCA" ``` ### Step 2: Create Server Certificate Next, we will create a server certificate that will be signed by the CA. The server certificate will be used to authenticate the server to the client: ```bash # Generate server private key openssl genrsa -out server-key.pem 4096 # Generate server certificate signing request openssl req -new -key server-key.pem -out server.csr -subj "/CN=localhost" # Sign server certificate with CA openssl x509 -req -days 365 -in server.csr -CA ca-cert.pem -CAkey ca-key.pem \ -CAcreateserial -out server-cert.pem ``` ### Step 3: Create Client Certificate Similarly, we will create a client certificate that will also be signed by the CA. The client certificate will be used to authenticate the client to the server. This is where the mutual aspect of mTLS comes into play, where client is also required to get a certificate: ```bash # Generate client private key openssl genrsa -out client-key.pem 4096 # Generate client certificate signing request openssl req -new -key client-key.pem -out client.csr -subj "/CN=client" # Sign client certificate with CA openssl x509 -req -days 365 -in client.csr -CA ca-cert.pem -CAkey ca-key.pem \ -CAcreateserial -out client-cert.pem -extfile client-ext.cnf ``` ### Step 4: Create PKCS12 Keystores We have to create key stores for both the server and client: ```bash # Create server-keystore.p12 (contains server certificate and private key) openssl pkcs12 -export -in server-cert.pem -inkey server-key.pem \ -out server-keystore.p12 -name server -password pass:serverkeypass \ -certfile ca-cert.pem # Create client-keystore.p12 (contains client certificate and private key) openssl pkcs12 -export -in client-cert.pem -inkey client-key.pem \ -out client-keystore.p12 -name client -password pass:clientkeypass \ -certfile ca-cert.pem ``` ### Step 5: Create PKCS12 Truststores Both the server and client need a strustore which contains the CA certificate: ```bash # Server truststore - Server needs to trust the CA that signed client certificates keytool -importcert -file ca-cert.pem \ -keystore server-truststore.p12 \ -storetype PKCS12 \ -storepass servertrustpass \ -alias ca \ -noprompt \ -trustcacerts # Client truststore - Client needs to trust the CA that signed server certificates keytool -importcert -file ca-cert.pem \ -keystore client-truststore.p12 \ -storetype PKCS12 \ -storepass clienttrustpass \ -alias ca \ -noprompt \ -trustcacerts ``` ## Implementation Example Before we start coding, let's set up the project structure. We will create a ZIO HTTP project with the following directory structure: ### Project Structure ``` src/main/ ├── scala/example/ssl/mtls/ │ ├── ServerApp.scala │ └── ClientApp.scala └── resources/certs/mtls/ ├── server-keystore.p12 # Server's keystore (private key + certificate) ├── server-truststore.p12 # Server's trust store (contains CA cert) ├── client-keystore.p12 # Client's keystore (private key + certificate) ├── client-truststore.p12 # Client's trust store (cotains CA cert) └── ca-cert.pem # Root CA certificate ``` ### Server Implementation The difference between one-way TLS and mutual TLS (mTLS) in server implementation lies in the direction of authentication. In one-way TLS, the server is only responsible for presenting its certificate to the client. Therefore, it is configured with a keystore that contains its certificate along with the server private key. In contrast, mTLS requires both parties to authenticate each other. The server must also verify the client's certificate during the TLS handshake. To do this, the server is configured with both a keystore (containing its own certificate and private key) and a truststore (containing the CA certificate used to sign the client's certificate). ```scala object ServerApp extends ZIOAppDefault { val routes: Routes[Any, Response] = Routes( Method.GET / "hello" -> handler { (req: Request) => ZIO.debug(req.remoteCertificate) *> ZIO.succeed( Response.text("Hello from TLS server! Connection secured!"), ) }, ) private val sslConfig = SSLConfig.fromJavaxNetSsl( data = SSLConfig.Data.FromJavaxNetSsl( keyManagerSource = FromJavaxNetSsl.Resource("certs/mtls/server-keystore.p12"), keyManagerPassword = Some(Secret("serverkeypass")), trustManagerKeyStore = Some( SSLConfig.Data.TrustManagerKeyStore( trustManagerSource = FromJavaxNetSsl.Resource("certs/mtls/server-truststore.p12"), trustManagerPassword = Some(Secret("servertrustpass")), ), ), ), includeClientCert = false, clientAuth = Some(ClientAuth.Required), protocols = Seq("TLSv1.3", "TLSv1.2"), ) private val serverConfig = ZLayer.succeed { Server.Config.default .port(8443) .ssl(sslConfig) } override val run = Server.serve(routes).provide(serverConfig, Server.live) } ``` Please note that we enabled the `ClientAuth.Required` option in the SSL configuration. This forces clients to present their certificates during the TLS handshake. If a client does not provide a valid certificate, the connection will be rejected. If we want to access the client certificate, we can enable the `includeClientCert` option in the SSL configuration. This allows us to access the client certificate via `req.remoteCertificate` in the request handler. The `protocols` parameter in `SSLConfig` allows configuring supported TLS protocol versions. This is useful for disabling older protocol versions for security reasons. ### Client Implementation Similarly, the client implementation for mTLS requires both a keystore (containing the client's certificate and private key) and a truststore (containing the CA certificate used to verify the server's certificate). The client will automatically send its certificate during the TLS handshake if configured correctly: ```scala object ClientApp extends ZIOAppDefault { val app: ZIO[Client, Throwable, Unit] = for { _ <- Console.printLine("Making secure HTTPS requests...") textResponse <- Client.batched( Request.get("https://localhost:8443/hello"), ) textBody <- textResponse.body.asString _ <- Console.printLine(s"Text response: $textBody") } yield () private val config = ZClient.Config.default.ssl( ClientSSLConfig.FromJavaxNetSsl( keyManagerSource = ClientSSLConfig.FromJavaxNetSsl.Resource("certs/mtls/client-keystore.p12"), keyManagerPassword = Some(Secret("clientkeypass")), trustManagerSource = ClientSSLConfig.FromJavaxNetSsl.Resource("certs/mtls/client-truststore.p12"), trustManagerPassword = Some(Secret("clienttrustpass")), ), ) override val run = app.provide( ZLayer.succeed(config), ZLayer.succeed(NettyConfig.default), DnsResolver.default, ZClient.live, ) } ``` ## How mTLS Works As you see the diagram, during the handshake process both the client and server exchange their certificates. The client sends its certificate to the server, and the server sends its certificate to the client. This allows both parties to verify each other's identities: ``` Client Server | | |-------------- ClientHello ------------------->| |<------------- ServerHello --------------------| |<------------- Server Certificate -------------| -> Server Certificate |<------------- ServerHelloDone ----------------| |-------------- Client Certificate ------------>| -> Client Certificate |-------------- ClientHelloDone --------------->| | | |-------------- ClientKeyExchange ------------->| |-------------- ChangeCipherSpec -------------->| |-------------- Finished ---------------------->| |<------------- ChangeCipherSpec ---------------| |<------------- Finished -----------------------| | | |<========= Encrypted Application Data ========>| ``` ## Conclusion Mutual TLS provides the strongest form of authentication in TLS, ensuring both parties verify each other's identity through certificates. It's essential for high-security environments, zero-trust architectures, and service-to-service communication. In this guide, we covered how to implement mTLS in a ZIO HTTP application, including generating certificates, configuring the server and client, and understanding the handshake process. With mTLS, you can build highly secure systems where every connection is authenticated, authorized, and encrypted, providing defense in depth for your critical applications. --- ## Implementing TLS with an Intermediate CA-signed Server Certificate ## Introduction In production environments, server certificates are rarely signed directly by root Certificate Authorities. Instead, they use intermediate CAs to create a certificate chain. This approach provides better security, flexibility, and follows industry best practices. This article demonstrates implementing TLS with certificate chains using intermediate CAs, with practical examples in Scala using the ZIO HTTP library. ## Understanding Certificate Chains Root CAs are extremely valuable and must be protected at all costs. If a root CA's private key is compromised, it affects every certificate it has ever signed. To minimize risk: 1. **Root CAs are kept offline**: Private keys stored in secure, air-gapped systems 2. **Intermediate CAs handle daily operations**: These can be revoked if compromised 3. **Isolation of risk**: Compromise of an intermediate CA has limited impact ### Certificate Chain Structure ``` ┌──────────────────────────┐ │ Root CA │ Self-signed, in trust stores │ CN=GlobalTrust Root │ Validity: 20-30 years └───────────┬──────────────┘ │ Signs ▼ ┌──────────────────────────┐ │ Intermediate CA │ Signed by Root CA │ CN=GlobalTrust SSL CA │ Validity: 5-10 years └───────────┬──────────────┘ │ Signs ▼ ┌──────────────────────────┐ │ Server Certificate │ Signed by Intermediate │ CN=www.example.com │ Validity: 90 days - 2 years └──────────────────────────┘ ``` ### Real-World Example Let's examine a real certificate chain from a major site, e.g. google.com: ```bash # Check Google's certificate chain openssl s_client -connect google.com:443 -showcerts < /dev/null # You'll see something like: # 0 s:CN=*.google.com (server certificate) # 1 s:C=US, O=Google Trust Services, CN=WR2 (intermediate) # 2 s:C=US, O=Google Trust Services LLC, CN=GTS Root R1 (root) ``` ## Creating a Certificate using Intermediate CA In this tutorial, we will create root and intermediate CAs to simulate the process of signing a server certificate with an intermediate CA. This will help you understand how to set up a secure TLS environment using certificate chains. In real-world scenarios, you can obtain certificates for your servers from well-known Certificate Authorities and no need to create your own CAs, unless you are managing your own PKI (Public Key Infrastructure) in an internal network. ### Step 1: Create Root CA ```bash # Generate Root CA private key (keep this extremely secure!) openssl genrsa -out root-ca-key.pem 4096 # Generate Root CA certificate (self-signed, valid for 10 years) openssl req -new -x509 -days 3650 -key root-ca-key.pem -out root-ca-cert.pem \ -subj "/C=Country/ST=State/L=City/O=RootCA/OU=Security/CN=Root CA" ``` ### Step 2: Create Intermediate CA Generate intermediate CA private key: ```bash openssl genrsa -out intermediate-ca-key.pem 4096 ``` Then generate Intermediate CA certificate signing request (CSR): ```bash openssl req -new -key intermediate-ca-key.pem -out intermediate-ca.csr \ -subj "/C=US/ST=State/L=City/O=IntermediateCA/OU=Security/CN=Intermediate CA" ``` Create extensions file for Intermediate CA: ```bash cat > intermediate-ca-ext.cnf << EOF basicConstraints = CA:TRUE, pathlen:0 keyUsage = digitalSignature, keyCertSign, cRLSign subjectKeyIdentifier = hash authorityKeyIdentifier = keyid:always,issuer:always EOF ``` Explanation of all these fields is beyond the scope of this article, but generally this configuration file defines the extensions that make a certificate function as an intermediate CA. Now we can sign Intermediate CA certificate with Root CA: ```bash openssl x509 -req -days 1825 -in intermediate-ca.csr \ -CA root-ca-cert.pem -CAkey root-ca-key.pem \ -CAcreateserial -out intermediate-ca-cert.pem \ -extfile intermediate-ca-ext.cnf ``` ### Step 3: Create Server Certificate To generate server certificate, first we should generate a private key for the server: ```bash openssl genrsa -out server-key.pem 4096 ``` Then, we have to generate server certificate signing request (CSR): ```bash # Generate server certificate signing request openssl req -new -key server-key.pem -out server.csr \ -subj "/C=Country/ST=State/L=City/O=MyCompany/OU=IT/CN=localhost" ``` Now we can sign server certificate with intermediate CA using the CSR configuration: ```bash openssl x509 -req -days 365 -in server.csr \ -CA intermediate-ca-cert.pem -CAkey intermediate-ca-key.pem \ -CAcreateserial -out server-cert.pem ``` ### Step 4: Create Server Keystore Now that we have the server certificate signed by the intermediate CA, we need to create a keystore that contains both the server certificate and the intermediate CA certificate. This is crucial because during the TLS handshake, the server will send its certificate along with the intermediate CA certificate to the client. During the handshake, the client will receive both the server certificate and the intermediate CA certificate. The client will then verify the server certificate against the intermediate CA certificate, which in turn is signed by the root CA already present in the client's trust store. Now, let's create the server keystore with the certificate chain: ```bash openssl pkcs12 -export -in server-cert.pem -inkey server-key.pem \ -out server-keystore.p12 -name server -password pass:keystorepass \ -certfile intermediate-ca-cert.pem \ -caname intermediate ``` ## Step 5: Create Client Truststore The client only needs the Root CA certificate, so let's create a trust store containing the Root CA: ```bash keytool -importcert -file root-ca-cert.pem \ -keystore client-truststore.p12 \ -storetype PKCS12 \ -storepass clienttrustpass \ -alias rootca \ -noprompt \ -trustcacerts ``` ## Implementation Example ### Project Structure Before we start coding, let's set up the project structure. We will create a ZIO HTTP project with the following directory structure: ``` src/main/ ├── scala/example/ssl/tls/intermediatecasigned/ │ ├── ServerApp.scala │ └── ClientApp.scala └── resources/certs/tls/intermediate-ca-signed/ ├── server-keystore.p12 # Server keystore with full chain └── client-truststore.p12 # Client truststore with Root CA only ``` ### Server Implementation ```scala object ServerApp extends ZIOAppDefault { val routes: Routes[Any, Response] = Routes( Method.GET / "hello" -> handler { Response.text( """Hello from TLS server with certificate chain! |The server sent a certificate chain for verification. |""".stripMargin, ) }, ) // SSL configuration using PKCS12 keystore with certificate chain private val sslConfig = SSLConfig.fromJavaxNetSslKeyStoreResource( keyManagerResource = "certs/tls/intermediate-ca-signed/server-keystore.p12", keyManagerPassword = Some(Secret("serverkeystore")), ) private val serverConfig = ZLayer.succeed { Server.Config.default .port(8443) .ssl(sslConfig) } override val run = { { for { _ <- Console.printLine("Certificate Chain TLS Server starting on https://localhost:8443/") _ <- Console.printLine("Endpoint:") _ <- Console.printLine(" - https://localhost:8443/hello : Basic hello endpoint") _ <- Console.printLine("\nThe server will send the following certificate chain during the SSL handshake:") _ <- Console.printLine(" 1. Server Certificate (signed by Intermediate CA)") _ <- Console.printLine(" 2. Intermediate CA Certificate (signed by Root CA)") _ <- Console.printLine("\nPress Ctrl+C to stop...") } yield () } *> Server.serve(routes).provide(serverConfig, Server.live) } } ``` **Key Points:** - Server keystore contains the complete certificate chain - The server automatically sends both server and intermediate certificates - Root CA certificate is not sent (clients should already have it) ### Client Implementation ```scala object ClientApp extends ZIOAppDefault { val app: ZIO[Client, Throwable, Unit] = for { _ <- Console.printLine("\nMaking HTTPS request to /hello") helloResponse <- ZClient.batched(Request.get("https://localhost:8443/hello")) helloBody <- helloResponse.body.asString _ <- Console.printLine(s"Response Status: ${helloResponse.status}") _ <- Console.printLine(s"Response: $helloBody") } yield () override val run = app.provide( ZLayer.succeed { ZClient.Config.default.ssl( ClientSSLConfig.FromTrustStoreResource( trustStorePath = "certs/tls/intermediate-ca-signed/client-truststore.p12", trustStorePassword = "clienttrustpass", ), ) }, ZLayer.succeed(NettyConfig.default), DnsResolver.default, ZClient.live, ) } ``` **Key Points:** - Client only needs the Root CA in its trust store - Intermediate certificate is provided by the server during SSL handshake - Chain verification happens automatically ## How Certificate Chains Work ### The TLS Handshake with Certificate Chains ``` Client Server | | |-------------- ClientHello ------------------->| | | |<------------- ServerHello --------------------| |<------------- Certificate --------------------| | [Server Cert] | | [Intermediate CA Cert] | |<------------- ServerHelloDone ----------------| | | | [Certificate Chain Validation] | | 1. Build certificate path | | 2. Verify each signature in chain | | 3. Check trust anchor (Root CA) | | 4. Check Certificate Validity ✓ | | 5. Verify hostname | | | |-------------- ClientKeyExchange ------------->| |-------------- ChangeCipherSpec -------------->| |-------------- Finished ---------------------->| | | |<------------- ChangeCipherSpec ---------------| |<------------- Finished -----------------------| | | |========== Encrypted Application Data =========| ``` ## Running the Example ### 1. Start the Server To run the server, open a terminal and execute the following command: ```bash sbt "zioHttpExample/runMain example.ssl.tls.intermediatecasigned.ServerApp" ``` Output: ``` Certificate Chain TLS Server starting on https://localhost:8443/ Endpoint: - https://localhost:8443/hello : Basic hello endpoint The server will send the following certificate chain during the SSL handshake: 1. Server Certificate (signed by Intermediate CA) 2. Intermediate CA Certificate (signed by Root CA) Press Ctrl+C to stop... ``` ### 2. Run the Client To run the client, open a new terminal and execute: ```bash sbt "zioHttpExample/runMain example.ssl.tls.intermediatecasigned.ClientApp" ``` ## Conclusion Certificate chains with intermediate CAs provide essential security benefits by isolating risk and keeping root CAs offline. This approach is the industry standard used by major Certificate Authorities worldwide. Our ZIO HTTP implementation demonstrated how certificate chains work in practice: servers automatically present the complete chain during TLS handshake, while clients only need to trust the root CA. This delegation of trust creates a scalable and secure architecture. This foundation prepares you to build production-ready applications that meet enterprise security standards in modern distributed systems. In the [next article](implementing-mutual-tls.md), we'll explore mutual TLS (mTLS), where both client and server present certificates for bidirectional authentication. --- ## Implementing TLS with Root CA-Signed Server Certificate ## Introduction Root Certificate Authority (CA) signed certificates form the backbone of trust on the internet. Unlike self-signed certificates, CA-signed certificates are validated by a trusted third party, enabling secure communication without manual trust configuration. This article demonstrates implementing TLS with CA-signed certificates, using examples in Scala with the ZIO HTTP library. Please note that this article focuses specifically on implementing CA-signed certificates rather than the administrative aspects of certificate management. While we won't cover the detailed processes of establishing a production certificate authority or purchasing certificates from commercial CAs, we will create a root certificate authority manually to demonstrate the core implementation concepts you would encounter in a production environment. ## Understanding CA-Signed Certificates ### What is a CA-Signed Certificate? A CA-signed certificate is a digital certificate that has been: - Verified and signed by a trusted Certificate Authority - Validated to ensure the requester controls the domain - Issued with the CA's digital signature ``` ┌─────────────────────────┐ │ Root CA Certificate │ │ (In Trust Stores) │ └───────────┬─────────────┘ | │ Signs | ▼ ┌─────────────────────────┐ │ Server Certificate │ ├─────────────────────────┤ │ Subject: CN=example.com │ │ Issuer: CN=Root CA │ │ Signed by: CA's key │ └─────────────────────────┘ ``` ### Trust Model The trust model for CA-signed certificates relies on a hierarchy of trust. Root CAs are pre-installed in operating systems and browsers, or manually added to trust stores. When a server presents a certificate signed by a trusted CA, clients can verify the certificate's authenticity without manual intervention. Any certificate signed by these root CAs is automatically trusted. ## Creating a Private CA for Development ### Step 1: Create Root CA For this example, we'll create our own Root CA to simulate the production process: ```bash # Generate Root CA Private Key openssl genrsa -out ca-key.pem 4096 # Generate Root CA Certificate openssl req -new -x509 -days 3650 -key ca-key.pem -out ca-cert.pem \ -subj "/C=US/ST=State/L=City/O=Example CA/OU=IT/CN=Example Root CA" ``` ### Step 2: Create Server Certificate Signing Request (CSR) The next step is to create a server certificate signed by this Root CA. To do this, we have to create a Certificate Signing Request (CSR) for the server, which should be signed by the Root CA. To create a CSR, we have to provide the server's private key and specify the subject details. Let's generate a private key for the server: ```bash # Generate Server Private Key openssl genrsa -out server-key.pem 4096 ``` Now, we are ready to generate the server's CSR: ```bash # Generate Server Certificate Signing Request (CSR) openssl req -new -key server-key.pem -out server.csr \ -subj "/C=US/ST=State/L=City/O=Example Server/OU=IT/CN=localhost" ``` ### Step 3: Sign Server Certificate with Root CA Before signing the server certificate, we need to create an extensions file that specifies the certificate's properties, such as key usage and subject alternative names (SANs): ```bash # Create Extensions File for Server Certificate cat > server-ext.cnf << EOF subjectAltName = DNS:localhost,IP:127.0.0.1 keyUsage = digitalSignature, keyEncipherment extendedKeyUsage = serverAuth EOF ``` Now it is time to sign the server certificate using the Root CA's private key: ```bash # Sign Server Certificate With Root CA openssl x509 -req -days 365 -in server.csr -CA ca-cert.pem -CAkey ca-key.pem \ -CAcreateserial -out server-cert.pem -extfile server-ext.cnf ``` ### Step 4: Create Server Keystore When using TLS, the server needs a keystore that contains its private key and certificate. We will create a PKCS12 keystore that includes the server's private key and the signed certificate: ```bash # Create server-keystore.p12 (Contains server-cert.pem and server-key.pem) openssl pkcs12 -export -in server-cert.pem -inkey server-key.pem \ -out server-keystore.p12 -name server -password pass:serverkeypass ``` ### Step 5: Create Client Trust Store The client needs a trust store that contains the Root CA certificate. This allows the client to verify the server's certificate during the TLS handshake. ```bash # Create client-truststore.p12 (Contains ca-cert.pem) keytool -importcert -file ca-cert.pem \ -keystore client-truststore.p12 \ -storetype PKCS12 \ -storepass clienttrustpass \ -alias ca \ -noprompt \ -trustcacerts ``` Please note the difference between the client trust store configuration in this tutorial and the previous one. In the previous tutorial, we used a self-signed certificate for the server, which required importing that specific server certificate into the client trust store. However, in this tutorial, we're using a Root CA-signed certificate for the server, so we only need to import the Root CA certificate into the client trust store. This allows the client to trust any certificate signed by that Root CA, including our server's certificate. Now all the cryptographic materials are ready for our TLS implementation. Let's move on to the actual implementation of client and server applications. ## Implementation Example ### Project Structure Before we start coding, let's set up the project structure. We will create a ZIO HTTP project with the following directory structure: ``` src/main/ ├── scala/example/ssl/tls/rootcasigned/ │ ├── ServerApp.scala │ └── ClientApp.scala └── resources/certs/tls/root-ca-signed/ ├── server-keystore.p12 # Server's private key and certificate └── client-truststore.p12 # Client's truststore with Root CA ``` ### Server Implementation ```scala object ServerApp extends ZIOAppDefault { val routes: Routes[Any, Response] = Routes( Method.GET / "hello" -> handler(Response.text("Hello from TLS server! Connection secured!")) ) private val sslConfig = SSLConfig.fromJavaxNetSslKeyStoreResource( keyManagerResource = "certs/tls/root-ca-signed/server-keystore.p12", keyManagerPassword = Some(Secret("serverkeypass")) ) private val serverConfig = ZLayer.succeed { Server.Config.default .port(8443) .ssl( sslConfig ) } override val run = Server.serve(routes).provide(serverConfig, Server.live) } ``` **Key Points:** - Server uses a certificate signed by the Root CA - Only needs its own keystore (private key + certificate) - No trust store needed for basic TLS (only for mTLS) ### Client Implementation The client will connect to the server using the Root CA's certificate in its trust store. This allows it to verify the server's certificate without manual configuration. ```scala object ClientApp extends ZIOAppDefault { val app: ZIO[Client, Throwable, Unit] = for { _ <- Console.printLine("Making secure HTTPS requests...") textResponse <- Client.batched( Request.get("https://localhost:8443/hello"), ) textBody <- textResponse.body.asString _ <- Console.printLine(s"Text response: $textBody") } yield () private val sslConfig = ZClient.Config.default.ssl( ClientSSLConfig.FromTrustStoreResource( "certs/tls/root-ca-signed/client-truststore.p12", "clienttrustpass", ) ) override val run = app.provide( ZLayer.succeed(sslConfig), ZLayer.succeed(NettyConfig.default), DnsResolver.default, ZClient.live, ) } ``` **Key Points:** - Client only needs the Root CA in its trust store - Automatically trusts any certificate signed by this Root CA - No need to import individual server certificates ## How It Works ### Certificate Validation Process When a client receives a CA-signed certificate from the server during the TLS handshake, it performs a comprehensive validation process to establish trust. Here's a simplified overview of the steps involved: 1. **Find the Certificate Authority** - Client looks at who signed the server's certificate and searches its trust store for that CA's certificate 2. **Verify the Signature** - Uses the CA's public key to verify the server certificate is genuine and was actually signed by the trusted CA 3. **Check Certificate Validity** - Ensures the certificate hasn't expired and is being used within its valid date range 4. **Verify the Hostname** - Checks that the certificate was issued for the correct server by matching the server name with what's in the certificate If all checks pass, the connection proceeds securely. If any check fails, the connection is rejected with an error. ``` Client Server | | |-------------- ClientHello ------------------->| | | |<------------- ServerHello --------------------| |<------------- Certificate --------------------| ← Server Cert |<------------- ServerHelloDone ----------------| | | | [Certificate Validation Process] | | 1. Extract issuer and Find it in truststore ✓ | | 2. Verify the Signature ✓ | | 4. Check Certificate Validity ✓ | | 5. Verify the Hostname ✓ | | | |-------------- ClientKeyExchange ------------->| |-------------- ChangeCipherSpec -------------->| |-------------- Finished ---------------------->| | | |<------------- ChangeCipherSpec ---------------| |<------------- Finished -----------------------| | | |========== Encrypted Application Data ======-==| ``` The key advantage of CA-signed certificates is that clients already trust the Root CA certificates pre-installed in their system, enabling automatic verification without any manual configuration. This makes the validation process significantly more streamlined compared to self-signed certificates, where each certificate must be manually added to the client's trust store to establish trust. ## Running the Example ### 1. Generate Certificates (One-time setup) Run the certificate generation script ```bash cd src/main/resources/certs/tls/root-ca-signed ./generate-certificates.sh ``` ### 2. Start the Server ```bash sbt "zioHttpExample/runMain example.ssl.tls.rootcasigned.ServerApp" ``` ### 3. Run the Client ```bash sbt "zioHttpExample/runMain example.ssl.tls.rootcasigned.ClientApp" ``` Output: ``` Making secure HTTPS requests... Text response: Hello from TLS server! Connection secured! ``` ## Conclusion CA-signed certificates provide the foundation for secure communication on the internet. By leveraging the existing trust infrastructure, they enable seamless secure connections without manual configuration. Key takeaways: - CA-signed certificates are automatically trusted by clients - The trust model relies on pre-installed root certificates - Production certificates should come from recognized CAs - Private CAs are excellent for development and internal services In the [next article](implementing-tls-with-intermediate-ca-signed-server-certificate.md), we'll explore certificate chains with intermediate CAs, which provide additional security and flexibility in certificate management. --- ## Implementing TLS with Self-signed Server Certificate ## Introduction Self-signed certificates are TLS/SSL certificates that are signed by the same entity that creates them, rather than by a trusted Certificate Authority (CA). While not suitable for production public-facing applications, they are invaluable for development, testing, and internal services. This tutorial demonstrates how to implement TLS using self-signed certificates with ZIO HTTP, covering certificate generation, server configuration, client setup, and security considerations. By the end of this guide, you'll understand how to create a complete TLS-enabled application using self-signed certificates. ## Understanding Self-signed Certificates A self-signed certificate is a digital certificate that is signed by the same entity that it certifies, rather than by a trusted Certificate Authority. Unlike CA-issued certificates, self-signed certificates create their own chain of trust, making them both the issuer and the subject. ``` ┌─────────────────────────┐ │ Self-signed Cert │ ├─────────────────────────┤ │ Subject: CN=localhost │ │ Issuer: CN=localhost │ ← The issuer is the same as the subject! │ Signed by: Own key │ └─────────────────────────┘ ``` This means the certificate's issuer and subject are the same entity - essentially, they're vouching for themselves. Unlike regular certificates that come with built-in trust because they're backed by recognized authorities, self-signed certificates don't have this third-party validation. Therefore, when users encounter a self-signed certificate, their browsers or applications typically show security warnings because there's no established chain of trust. To use self-signed certificates properly, administrators must manually add them to each client's trusted certificate store, instructing the system to accept and trust that specific certificate. While this manual process can be time-consuming for large organizations, self-signed certificates are popular for internal company networks, testing environments, and situations where the cost and complexity of obtaining CA-issued certificates isn't justified by the security requirements. ### When to Use Self-signed Certificates Self-signed certificates are well-suited for specific scenarios where public trust isn't required and security can be managed through controlled environments. They work effectively in development and testing environments where developers need SSL/TLS encryption for local servers and applications without the overhead of obtaining CA-issued certificates. Internal microservices communication within private networks is another use case, as services can trust each other through pre-configured certificate stores without needing external validation. However, please note that this configuration has drawbacks, such as the need for manual trust management and revocation processes, which can become cumbersome as the number of services grows. They're also valuable for proof of concepts, demos, and experimental projects where quick setup is more important than established trust chains. However, self-signed certificates should never be used for public-facing production websites, e-commerce applications, or any service that requires users to trust the connection without manual certificate installation. In these public scenarios, the security warnings and trust barriers they create can damage user confidence, reduce conversion rates, and potentially expose users to man-in-the-middle attacks if they're trained to ignore certificate warnings. The general rule is that self-signed certificates are appropriate for closed, controlled environments where administrators can manage trust relationships directly, but they're unsuitable for any application where unknown users need to establish trust automatically. ## Generating Self-signed Certificates The certificate generation process involves creating a private key, generating a self-signed certificate, and preparing keystores for both server and client use: ```bash openssl req -x509 -newkey rsa:4096 -keyout server-key.pem \ -out server-cert.pem -days 365 -nodes \ -subj "/CN=localhost" ``` With this command, we generate a new RSA private key (`server-key.pem`) and a self-signed certificate valid for 365 days (`server-cert.pem`). The `-subj` option specifies the subject details, which in this case is set to `CN=localhost`, indicating that the certificate is intended for use with the localhost domain. ### Generating the Server Key Store To create a PKCS12 keystore that combines the private key and certificate into a single file, which is easier to manage in Java applications: ```bash openssl pkcs12 -export -in server-cert.pem \ -inkey server-key.pem \ -out server-keystore.p12 -name server -password pass:serverkeypass ``` The `server-keystore.p12` file is a password-protected keystore containing the private key and certificate. Later, we can use this keystore in our ZIO HTTP server configuration. ### Creating the Client Trust Store Since the certificate is self-signed, clients need to explicitly trust it. Therefore, we need to create a truststore that contains the server's certificate: ```bash keytool -importcert -file server-cert.pem \ -keystore client-truststore.p12 \ -storetype PKCS12 \ -storepass clienttrustpass \ -alias server \ -noprompt ``` Now we are ready to implement the server and client applications. ## Implementation Before we start coding, let's set up the project structure. We will create a ZIO HTTP project with the following directory structure: ``` src/main/ ├── scala/example/ssl/tls/selfsigned/ │ ├── ServerApp.scala │ └── ClientApp.scala └── resources/certs/tls/self-signed/ ├── server-keystore.p12 # Server keystore with private key and certificate ├── client-truststore.p12 # Client truststore with server certificate ├── server-cert.pem # PEM format certificate └── server-key.pem # PEM format private key ``` ### Server Implementation To set up a self-signed TLS server using ZIO HTTP, we need to load the server's private key and certificate. We can either use `SSLConfig.fromJavaxNetSslKeyStoreResource` or `SSLConfig.fromResource`. The first option uses keystores, and the second one uses PEM files directly: ```scala // Option 1: Using PKCS12 keystore private val sslConfig = SSLConfig.fromJavaxNetSslKeyStoreResource( keyManagerResource = "certs/tls/self-signed/server-keystore.p12", keyManagerPassword = Some(Secret("keystorepass")), ) // Option 2: Using PEM files directly // Note: This might require the PEM files to be in the correct format private val sslConfigPem = SSLConfig.fromResource( certPath = "certs/tls/self-signed/server-cert.pem", keyPath = "certs/tls/self-signed/server-key.pem", ) ``` After loading the SSL configuration, we can set up the server to listen on a specific port (e.g., 8443) and serve requests over HTTPS: ```scala object ServerApp extends ZIOAppDefault { val routes: Routes[Any, Response] = Routes( Method.GET / "hello" -> handler(Response.text("Hello from self-signed TLS server! Connection secured!")), ) private val serverConfig = ZLayer.succeed { Server.Config.default .port(8443) .ssl(sslConfig) // Using PKCS12 keystore } val run = Console.printLine("Self-signed TLS Server starting on https://localhost:8443/") *> Server.serve(routes).provide(serverConfig, Server.live) } ``` **Key Points:** - The server loads its private key and certificate from a PKCS12 keystore - The `SSLConfig` handles all TLS configuration - The server listens on port 8443 (standard HTTPS alternative port) Now that the server is ready, let's move on to the client implementation. ### Client Implementation Similar to the server, the client needs to be configured with specific SSL settings, but this time it will use a truststore that contains the server's self-signed certificate. This allows the client to trust the server's certificate during the TLS handshake: ```scala val sslConfig = ZLayer.succeed { ZClient.Config.default.ssl( ClientSSLConfig.FromTrustStoreResource( trustStorePath = "certs/tls/self-signed/client-truststore.p12", trustStorePassword = "clienttrustpass", ) ) } ``` Please note that `ClientSSLConfig` provides several constructors for reading the truststore. It also includes a `ClientSSLConfig#Default` instance, which is useful when you want the client to ignore certificate verification. However, in this case, we want to ensure that the client trusts the self-signed certificate, so we use the `FromTrustStoreResource` constructor. Now we can implement the client application that will connect to the self-signed TLS server and make a secure HTTPS request: ```scala object ClientApp extends ZIOAppDefault { val app: ZIO[Client, Throwable, Unit] = for { _ <- Console.printLine("Making secure HTTPS request to self-signed server...") response <- Client.batched(Request.get("https://localhost:8443/hello")) body <- response.body.asString _ <- Console.printLine(s"Response status: ${response.status}") _ <- Console.printLine(s"Response body: $body") } yield () override val run = app.provide( sslConfig, ZLayer.succeed(NettyConfig.default), DnsResolver.default, ZClient.live, ) } ``` Note that without the truststore configuration, the client would reject the self-signed certificate, leading to an SSL handshake failure. The truststore allows the client to recognize and trust the server's self-signed certificate, enabling secure communication. ## How It Works The TLS handshake process with self-signed certificates follows these steps: 1. **Client Hello**: Client initiates connection and sends supported cipher suites 2. **Server Hello**: Server responds with chosen cipher suite and sends certificate 3. **Certificate Verification**: Client validates certificate against truststore 4. **Key Exchange**: Client and server establish shared encryption keys 5. **Encrypted Communication**: All subsequent communication is encrypted Unlike CA-issued certificates, self-signed certificates require explicit trust configuration. The client must have the server's certificate in its truststore to complete validation. ``` Client Server | | |-------------- ClientHello ------------------->| | | |<------------- ServerHello --------------------| |<------------- Certificate --------------------| ← Self-signed Server Cert |<------------- ServerHelloDone ----------------| | | | [Verify certificate against truststore] | | ✓ Found matching certificate in truststore | | | |-------------- ClientKeyExchange ------------->| |-------------- ChangeCipherSpec -------------->| |-------------- Finished ---------------------->| | | |<------------- ChangeCipherSpec ---------------| |<------------- Finished -----------------------| | | |========== Encrypted Application Data -========| ``` ## Running the Example ### 1. Start the Server ```bash sbt "zioHttpExample/runMain example.ssl.tls.selfsigned.ServerApp" ``` Output: ``` Self-signed TLS Server starting on https://localhost:8443/ ``` ### 2. Run the Client ```bash sbt "zioHttpExample/runMain example.ssl.tls.selfsigned.ClientApp" ``` Output: ``` Making secure HTTPS request to self-signed server... Response status: Ok Response body: Hello from self-signed TLS server! Connection secured! ``` ### 3. Testing with curl You can test the server using various curl configurations to understand different certificate validation scenarios: ```bash curl -v https://localhost:8443/hello ``` Running this command will show you the certificate verification process. Since the server uses a self-signed certificate, you will see an error about the certificate not being trusted unless you take additional steps to trust it: ``` * Trying [::1]:8443... * Connected to localhost (::1) port 8443 * ALPN: curl offers h2,http/1.1 * TLSv1.3 (OUT), TLS handshake, Client hello (1): * TLSv1.3 (IN), TLS handshake, Server hello (2): * TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8): * TLSv1.3 (IN), TLS handshake, Certificate (11): * TLSv1.3 (OUT), TLS alert, unknown CA (560): * OpenSSL/3.0.14: error:16000069:STORE routines::unregistered scheme * Closing connection curl: (35) OpenSSL/3.0.14: error:16000069:STORE routines::unregistered scheme ``` To successfully connect, you can use the following curl command: ```bash curl --cacert resources/certs/tls/self-signed/server-cert.pem https://localhost:8443/hello ``` This will print the following output: ``` Hello from self-signed TLS server! Connection secured! ``` You can use the `-v` option to see the details of the TLS handshake. Note that it is the client's responsibility to perform certificate verification. Therefore, if the client decides to ignore certificate verification, the connection will succeed without any errors, but it will not be secure: ```bash curl -k https://localhost:8443/hello ``` The `-k` option tells curl to skip certificate verification. ## Conclusion Self-signed certificates provide a simple way to enable TLS encryption during development and testing. While they lack third-party validation, they offer the same encryption strength as CA-signed certificates. The key is understanding their limitations and using them appropriately. Key takeaways: - Self-signed certificates are perfect for development and testing - Clients must explicitly trust self-signed certificates - Never use them for public-facing production services In the [next guide](implementing-tls-with-root-ca-signed-server-certificate.md), we'll explore using CA-signed certificates, which provide third-party validation and are suitable for production use. --- ## How to Integrate with ZIO Config When building HTTP applications, it is common to have configuration settings that need to be loaded from various sources such as environment variables, system properties, or configuration files. It is essential especially when deploying applications to different environments like development, testing, and production, or we want to have a cloud-native application that can be configured dynamically. ZIO HTTP provides seamless integration with [ZIO Config](https://zio.dev/zio-config/), a powerful configuration library for ZIO, to manage configurations in your HTTP applications. In this guide, we will learn how to integrate ZIO HTTP with ZIO Config to load configuration settings for our HTTP applications. ## ZIO Config Overview The ZIO core library has a built-in configuration system that allows us to define a type-safe configuration schema, load configurations from various sources, validate configurations, and access configuration settings in a functional way. We can define a configuration schema for any custom data type. For example, if we have a `DatabaseConfig` case class as follows: ```scala case class DatabaseConfig( url: String, username: String, password: String, poolSize: Int, ) ``` We can derive a configuration schema for `DatabaseConfig` using ZIO Config as follows: ```scala object DatabaseConfig { val config: Config[DatabaseConfig] = DeriveConfig.deriveConfig[DatabaseConfig] .mapKey(toSnakeCase) .nested("database") } ``` Now, we can load the configuration settings for `DatabaseConfig` by calling `ZIO.config(DatabaseConfig.config)`: ```scala object MainApp extends ZIOAppDefault { def run = { for { config <- ZIO.config(DatabaseConfig.config) _ <- ZIO.debug("Just started right now!") _ <- ZIO.debug(s"Connecting to the database: ${config.url}") } yield () } } ``` By default, ZIO will load the configs from environment variables, so we need to set the following environment variables: ```bash export DATABASE_URL="jdbc:postgresql://localhost:5432/mydb" export DATABASE_USERNAME="admin" export DATABASE_PASSWORD="password" export DATABASE_POOL_SIZE=10 ``` ## Loading Configuration Settings from a File As we mentioned earlier, by default, ZIO loads configurations from environment variables. However, we can change the `ConfigProvider` to load configurations from other sources such as system properties, console, and system properties. All of these are built-in providers in the ZIO core library. ZIO Config also provides more advanced `ConfigProvider`s such as HOCON, JSON, YAML, and XML. Based on the configuration format, we need to add one of the following dependencies to our project: ```scala libraryDependencies += "dev.zio" %% "zio-config-typesafe" % "4.0.7" // HOCON libraryDependencies += "dev.zio" %% "zio-config-yaml" % "4.0.7" // YAML and JSON libraryDependencies += "dev.zio" %% "zio-config-xml" % "4.0.7" // XML ``` Assuming we have an `application.conf` file inside the `resources` directory with the following content: ```hocon database { url: "jdbc:mysql://localhost:3306/mydatabase" url: ${?DATABASE_URL} username: "user" username: ${?DATABASE_USERNAME} password: "password" password: ${?DATABASE_PASSWORD} pool_size: 20 pool_size: ${?DATABASE_POOL_SIZE} } ``` Then, we can load it using the `ConfigProvider.fromResourcePath` method: ```scala object MainApp extends ZIOAppDefault { override val bootstrap: ZLayer[ZIOAppArgs, Any, Any] = Runtime.setConfigProvider(ConfigProvider.fromResourcePath()) def run = for { config <- ZIO.config(DatabaseConfig.config) _ <- ZIO.debug("Just started right now!") _ <- ZIO.debug(s"Connecting to the database: ${config.url}") } yield () } ``` ## Client and Server Configuration Both `Client` and `Server` have the `default` layer that requires no configuration and provides an instance of `Client` and `Server` with default settings: ```scala object Client { val default: ZLayer[Any, Throwable, Client] = ??? } object Server { val default: ZLayer[Any, Throwable, Server] = ??? } ``` In some cases, we need to customize the client or server settings such as timeouts, host, port, and other parameters. To do that, ZIO HTTP provides `live` and `customized` layers that require additional configuration settings: ```scala object Client { case class Config( // configuration settings for client ) val live : ZLayer[Client.Config with NettyConfig with DnsResolver, Throwable, Client] = ??? def customized: ZLayer[Client.Config with ClientDriver with DnsResolver, Throwable, Client] = ??? } object Server { case class Config( // configuration settings for server ) val live : ZLayer[Server.Config, Throwable, Server] = ??? val customized: ZLayer[Server.Config & NettyConfig, Throwable, Server] = ??? } ``` So, to have a customized client or server, we need to provide configuration layers to satisfy the required dependencies. For example, to create a `live` server, we need to provide a `ZLayer` that produces a `Server.Config`. For a practical example, see the following code which enables the response compression in the server: ```scala title="zio-http-example/src/main/scala/example/ServerResponseCompression.scala" //> using dep "dev.zio::zio-http:3.4.0" package example object ServerResponseCompression extends ZIOAppDefault { val routes = Routes( Method.GET / "hello" -> handler(Response.text("Hello, World!")), ).sandbox val config = ZLayer.succeed( Server.Config.default.copy( responseCompression = Some(Server.Config.ResponseCompressionConfig.default), ), ) def run = Server.serve(routes).provide(Server.live, config) } ``` In the above example, we updated the default server configuration to enable the response compression. Finally, we provided the `Server.live` and our customized config layer to the `Server.serve` method. ### Predefined Configuration Schemas Until now, we changed the server configuration programmatically inside the code. But what if we want to load the client or server configuration from a file, e.g. `application.conf`? We need to have a configuration schema for the client and server settings, i.e. `zio.Config[Client.Config]` and `zio.Config[Server.Config]`. Fortunately, ZIO HTTP provides these configuration schemas by default. Before going further, let's take a look at the `Server.Config` and `Client.Config` and see how are they defined in ZIO HTTP: ```scala object Client { case class Config( // configuration settings for client ) object Config { // Configuration Schema for Cleint.Config val config: zio.Config[Client.Config] = ??? // default configuration for Client.Config lazy val default: Client.Config = ??? } } object Server { case class Config( // configuration settings for server ) object Config { // configuration schema for Server.Config val config: zio.Config[Server.Config] = ??? // default configuration for Server.Config lazy val default: Server.Config = ??? } } ``` The `Server` and `Client` modules have predefined config schema, i.e. `Server.Config.config` and `Client.Config.config`, that can be used to load the server/client configuration from the environment, system properties, or any other configuration sources. ## Loading Configuration Settings from Environment Variables As the ZIO HTTP provided these configuration schemas by default, we can easily use them to load the configuration settings from the considered sources using the corresponding `ConfigProvider`: ```scala object MainApp extends ZIOAppDefault { def run = { Server .install( Routes( Method.GET / "hello" -> handler(Response.text("Hello, world!")), ), ) .flatMap(port => ZIO.debug(s"Sever started on http://localhost:$port") *> ZIO.never) .provide( Server.live, ZLayer.fromZIO( ZIO.config(Server.Config.config.mapKey(_.replace('-', '_'))), ), ) } } ``` ```shell export BINDING_HOST=localhost export BINDING_PORT=8081 ``` :::note In the above example, we used the `mapKey` method to replace the `-` character with `_` in the configuration keys. This is because the environment variables do not allow the `-` character in the key names. ::: ### Loading Configuration Settings from an HOCON File By changing the `ConfigProvider` to `ConfigProvider.fromResourcePath()`, we can load the server configuration from the `application.conf` file: ```hocon zio.http.server { binding_port: 8083 binding_host: localhot } ``` ```scala title="zio-http-example/src/main/scala/example/config/LoadServerConfigFromHoconFile.scala" //> using dep "dev.zio::zio-http:3.4.0" //> using dep "dev.zio::zio-config:4.0.5" //> using dep "dev.zio::zio-config-typesafe:4.0.5" package example.config object LoadServerConfigFromHoconFile extends ZIOAppDefault { override val bootstrap: ZLayer[ZIOAppArgs, Any, Any] = Runtime.setConfigProvider(ConfigProvider.fromResourcePath()) def run = { Server .install( Routes( Method.GET / "hello" -> handler(Response.text("Hello, world!")), ), ) .flatMap(port => ZIO.debug(s"Sever started on http://localhost:$port") *> ZIO.never) .provide( Server.live, ZLayer.fromZIO( ZIO.config(Server.Config.config.nested("zio.http.server").mapKey(_.replace('-', '_'))), ), ) } } ``` Instead of providing two layers (`Server.live` and `ZLayer.fromZIO(ZIO.config(Server.Config.config))`) to the `Server.serve` method, we can combine them into a single layer using the `Server.configured` layer: ```scala title="zio-http-example/src/main/scala/example/config/HoconWithConfiguredLayerExample.scala" //> using dep "dev.zio::zio-http:3.4.0" //> using dep "dev.zio::zio-config-typesafe:4.0.5" package example.config object HoconWithConfiguredLayerExample extends ZIOAppDefault { override val bootstrap: ZLayer[ZIOAppArgs, Any, Any] = Runtime.setConfigProvider(ConfigProvider.fromResourcePath()) def run = { Server .install( Routes( Method.GET / "hello" -> handler(Response.text("Hello, world!")), ), ) .flatMap(port => ZIO.debug(s"Sever started on http://localhost:$port") *> ZIO.never) .provide(Server.configured()) } } ``` ### Customized Layers If we need to have more control, the `Server` and `Client` companion objects have also `customized` layers that require additional configuration settings to customize the underlying settings for the server and client: - `Server.customized` is a layer that requires a `Server.Config` and `NettyConfig` and returns a `Server` layer. - `Client.customized` is a layer that requires a `Client.Config`, `NettyConfig`, and `DnsResolver` and returns a `Client` layer. ```scala ``` ```scala object Clinet { case class Config( // configuration settings for client ) val customized: ZLayer[Config with ClientDriver with DnsResolver, Throwable, Client] = ??? } object Server { case class Config( // configuration settings for server ) val customized: ZLayer[Config & NettyConfig, Throwable, Server] = ??? } ``` ## Summary In this guide, we learned how to integrate ZIO HTTP with ZIO Config to load configuration settings for our HTTP applications. We also learned how to load configuration settings from environment variables, system properties, and configuration files, such as HOCON and YAML using ZIO Config's configuration providers. --- ## Building a Real-time Chat with Datastar This guide walks through building a real-time multi-client chat application using ZIO HTTP and Datastar. The application demonstrates several powerful patterns for building reactive web applications with server-driven UI updates. ## What We're Building A fully functional chat application where: - Multiple users can join and chat simultaneously - Messages appear in real-time across all connected clients - No page refreshes required - updates stream via Server-Sent Events (SSE) - Clean, reactive UI with Datastar signal bindings ## Key Concepts Demonstrated - **ZIO Hub** for broadcasting messages to multiple subscribers - **Server-Sent Events (SSE)** for real-time updates via `events { handler {...} }` - **Datastar signals** for reactive form bindings - **Type-safe request handling** with `readSignals[T]` - **HTML templating** with the `template2` DSL ## Prerequisites Add the Datastar SDK dependency to your project: ```scala libraryDependencies += "dev.zio" %% "zio-http-datastar-sdk" % "3.8.1" ``` ## Architecture Overview The chat application consists of four components: 1. **ChatMessage** - Immutable message model with ZIO Schema 2. **ChatRoom** - In-memory state using `Ref` + message broadcasting via `Hub` 3. **MessageRequest** - Request model for signal binding 4. **ChatServer** - HTTP routes and HTML template ``` ┌─────────────────┐ POST /chat/send ┌─────────────────┐ │ Browser 1 │ ───────────────────────► │ │ │ (Datastar) │ │ ChatServer │ │ │ ◄─────────────────────── │ │ └─────────────────┘ SSE: new messages │ ┌─────────┐ │ │ │ ChatRoom│ │ ┌─────────────────┐ POST /chat/send │ │ (Hub) │ │ │ Browser 2 │ ───────────────────────► │ └─────────┘ │ │ (Datastar) │ │ │ │ │ ◄─────────────────────── │ │ └─────────────────┘ SSE: new messages └─────────────────┘ ``` ## Implementation ### 1. Message Model The `ChatMessage` case class represents a chat message with automatic ID and timestamp generation: ```scala title="zio-http-example-datastar-chat/src/main/scala/example/datastar/chat/ChatMessage.scala" package example.datastar.chat case class ChatMessage( id: String, username: String, content: String, timestamp: Long, ) object ChatMessage { def apply(username: String, content: String): ChatMessage = ChatMessage(java.util.UUID.randomUUID().toString, username, content, System.currentTimeMillis()) implicit val schema: Schema[ChatMessage] = DeriveSchema.gen[ChatMessage] } ``` Key points: - Uses ZIO Schema for type-safe serialization - Factory method generates UUID and timestamp automatically - Scala 3 `given` syntax for Schema derivation ### 2. Request Model The `MessageRequest` captures the form data sent when a user submits a message: ```scala title="zio-http-example-datastar-chat/src/main/scala/example/datastar/chat/MessageRequest.scala" package example.datastar.chat case class MessageRequest(username: String, message: String) object MessageRequest { implicit val schema: Schema[MessageRequest] = DeriveSchema.gen[MessageRequest] } ``` This model maps directly to the Datastar signals `$username` and `$message` defined in the HTML template. ### 3. Chat Room with Hub The `ChatRoom` manages message state and broadcasts new messages to all connected clients: ```scala title="zio-http-example-datastar-chat/src/main/scala/example/datastar/chat/ChatRoom.scala" package example.datastar.chat case class ChatRoom( messages: Ref[List[ChatMessage]], subscribers: Hub[ChatMessage], ) object ChatRoom { def make: ZIO[Any, Nothing, ChatRoom] = for { messages <- Ref.make(List.empty[ChatMessage]) hub <- Hub.unbounded[ChatMessage] } yield ChatRoom(messages, hub) def addMessage(message: ChatMessage): ZIO[ChatRoom, Nothing, Unit] = ZIO.serviceWithZIO[ChatRoom] { room => for { _ <- room.messages.update(_ :+ message) _ <- room.subscribers.publish(message) } yield () } def getMessages: ZIO[ChatRoom, Nothing, List[ChatMessage]] = ZIO.serviceWithZIO[ChatRoom](_.messages.get) def subscribe: ZIO[ChatRoom & Scope, Nothing, UStream[ChatMessage]] = ZIO.serviceWithZIO[ChatRoom] { room => room.subscribers.subscribe.map(ZStream.fromQueue(_)) } val layer: ZLayer[Any, Nothing, ChatRoom] = ZLayer.fromZIO(make) } ``` Key patterns: - **`Ref[List[ChatMessage]]`** - Thread-safe mutable reference for message history - **`Hub[ChatMessage]`** - Broadcasts messages to all subscribers - **`subscribe`** - Returns a `ZStream` that receives new messages - **ZLayer** - Provides the `ChatRoom` as a dependency ### 4. Server and Routes The `ChatServer` ties everything together with HTTP routes and the HTML template: ```scala title="zio-http-example-datastar-chat/src/main/scala/example/datastar/chat/ChatServer.scala" package example.datastar.chat object ChatServer extends ZIOAppDefault { private val $username = Signal[String]("username") private val $message = Signal[String]("message") private val chatPage: Dom = html( head( meta(charset := "UTF-8"), meta(name := "viewport", content := "width=device-width, initial-scale=1.0"), title("ZIO Chat - Real-time Multi-Client Chat"), datastarScript, style.inlineResource("chat.css"), ), body( dataInit := Endpoint(Method.GET / "chat" / "messages").out[String].datastarRequest(()), div(`class` := "header")( h1("ZIO Chat"), p( "Real-time Multi-Client Chat with ZIO, ZIO HTTP & Datastar", span(`class` := "connection-status")("CONNECTED"), ), ), div( `class` := "container", dataSignals($username) := "", dataSignals($message) := "", )( div(`class` := "username-section")( label(`for` := "username")("Your Username"), input( `type` := "text", id := "username", placeholder := "Enter your username...", dataBind("username"), ), ), div(`class` := "chat-container")( div( `class` := "messages", id := "messages", )( div(id := "message-list"), ), div(`class` := "input-area")( input( `type` := "text", id := "message", placeholder := "Type your message...", dataBind("message"), required, dataOn.keydown := js"evt.code === 'Enter' && @post('/chat/send')", ), button( `type` := "submit", dataAttr("disabled") := js"(${$username} === '' || ${$message} === '')", dataOn.click := js"@post('/chat/send')", )("Send"), ), ), ), script(js""" // Auto-scroll to bottom when new messages arrive const messagesContainer = document.getElementById('messages'); const observer = new MutationObserver(() => { messagesContainer.scrollTop = messagesContainer.scrollHeight; }); observer.observe(messagesContainer, { childList: true, subtree: true }); """), ), ) private def messageTemplate(msg: ChatMessage): Dom = { val time = Instant .ofEpochMilli(msg.timestamp) .atZone(ZoneId.systemDefault()) .format(DateTimeFormatter.ofPattern("HH:mm:ss")) div(`class` := "message")( div(`class` := "message-header")( span(`class` := "message-username")(msg.username), span(`class` := "message-time")(time), ), div(`class` := "message-content")(msg.content), ) } private val routes = Routes( Method.GET / "chat" -> handler { Response.text(chatPage.render).addHeader("Content-Type", "text/html") }, Method.GET / "chat" / "messages" -> events { handler { for { messages <- ChatRoom.getMessages _ <- ServerSentEventGenerator.patchElements( messages.map(messageTemplate), PatchElementOptions( selector = Some(id("message-list")), mode = ElementPatchMode.Inner, ), ) messages <- ChatRoom.subscribe _ <- messages.mapZIO { message => ServerSentEventGenerator.patchElements( messageTemplate(message), PatchElementOptions( selector = Some(id("message-list")), mode = ElementPatchMode.Append, ), ) }.runDrain } yield () } }, Method.POST / "chat" / "send" -> handler { (req: Request) => for { rq <- req.readSignals[MessageRequest] msg = ChatMessage(username = rq.username, content = rq.message) _ <- ChatRoom.addMessage(msg) } yield Response.ok }, ).sandbox @@ ErrorResponseConfig.debug @@ Middleware.debug override def run: ZIO[Any, Throwable, Unit] = Server .serve(routes) .provide( Server.default, ChatRoom.layer, ) } ``` Let's break down the key parts: #### Signal Declarations ```scala private val $username = Signal[String]("username") private val $message = Signal[String]("message") ``` These typed signal declarations are used in the HTML template for two-way data binding. #### HTML Template with Datastar The template uses several Datastar attributes: - **`datastarScript`** - Includes the Datastar JavaScript library - **`dataInit`** - Triggers initial data load via SSE when the page loads - **`dataSignals($username) := ""`** - Declares reactive signals with initial values - **`dataBind("username")`** - Two-way binds input value to signal - **`dataOn.keydown := js"..."`** - Handles keyboard events - **`dataOn.click := js"@post('/chat/send')"`** - Sends message on button click #### SSE Streaming Route ```scala Method.GET / "chat" / "messages" -> events { handler { for messages <- ChatRoom.getMessages _ <- ServerSentEventGenerator.patchElements( messages.map(messageTemplate), PatchElementOptions( selector = Some(id("message-list")), mode = ElementPatchMode.Inner, ), ) messages <- ChatRoom.subscribe _ <- messages.mapZIO { message => ServerSentEventGenerator.patchElements( messageTemplate(message), PatchElementOptions( selector = Some(id("message-list")), mode = ElementPatchMode.Append, ), ) }.runDrain yield () } } ``` This route: 1. Sends existing messages immediately (with `Inner` mode to replace content) 2. Subscribes to the Hub for new messages 3. Streams each new message as an SSE event (with `Append` mode) #### Message Sending Route ```scala Method.POST / "chat" / "send" -> handler { (req: Request) => for rq <- req.readSignals[MessageRequest] msg = ChatMessage(username = rq.username, content = rq.message) _ <- ChatRoom.addMessage(msg) yield Response.ok } ``` The `readSignals[T]` method extracts Datastar signals from the request body into a typed case class. ## Running the Example Clone the ZIO HTTP repository and run the example: ```bash git clone https://github.com/zio/zio-http.git cd zio-http sbt "zioHttpExampleDatastarChat/run" ``` Open your browser to [http://localhost:8080/chat](http://localhost:8080/chat). To test multi-client functionality, open multiple browser tabs or windows. ## How It Works 1. **Page Load**: Browser requests `/chat`, receives HTML with embedded Datastar 2. **Initial Connection**: `dataInit` triggers GET `/chat/messages`, establishing SSE connection 3. **Existing Messages**: Server sends all existing messages via `patchElements` with `Inner` mode 4. **Subscription**: Server subscribes to Hub and keeps connection open 5. **User Types**: Input changes update `$username` and `$message` signals locally 6. **User Sends**: Button click or Enter key triggers POST `/chat/send` with signals 7. **Broadcast**: Server adds message to ChatRoom, Hub broadcasts to all subscribers 8. **Real-time Update**: Each subscriber's SSE connection receives new message, DOM updates ## Styling The application uses an external CSS file loaded via `style.inlineResource("chat.css")`. This demonstrates how to load static resources in ZIO HTTP applications. ## Next Steps This example can be extended with: - **User authentication** - Add login flow before chat access - **Multiple rooms** - Support different chat channels - **Message persistence** - Store messages in a database - **Typing indicators** - Show when users are typing - **Read receipts** - Track message delivery status ## Related Documentation - [Datastar SDK Reference](../reference/datastar-sdk/index.md) - Complete API documentation - [Server-Sent Events](../examples/server-sent-events-in-endpoints.md) - SSE fundamentals - [HTML Templating](../reference/body/template.md) - Template DSL reference --- ## Securing Communication with SSL/TLS ZIO HTTP supports securing communication between entities—typically clients and servers—using SSL/TLS. This is crucial for protecting sensitive data in transit and ensuring both the integrity and authenticity of the communication. SSL (Secure Sockets Layer) and TLS (Transport Layer Security) are cryptographic protocols designed to provide secure communication over a network. Although the terms are often used interchangeably, TLS is the modern, more secure successor to SSL. SSL, originally developed in the 1990s, had several vulnerabilities, which led to the development of TLS as an improved replacement. Despite this, the term “SSL” is still commonly used out of habit—much like how people still say “dial a number” even though phones no longer have rotary dials. SSL/TLS relies on a system called Public Key Infrastructure (PKI), which uses digital certificates to verify the identities of the communicating parties and to establish secure connections. In simple terms, PKI acts like an identity check—it ensures a website is who it claims to be—while SSL/TLS establishes the encrypted connection. When you visit a secure website, PKI verifies the site's legitimacy through its certificate, and SSL/TLS creates an encrypted tunnel so your data can travel safely. PKI handles identity verification, and SSL/TLS handles secure communication. We aim to offer a comprehensive guide on this topic through a series of articles. Before diving into the details, it's important to understand the fundamental concepts. This article covers the core principles to help you build a strong foundation. In the following articles, we’ll explore each implementation approach in depth. If you're already familiar with PKI and SSL/TLS, feel free to skip this article and move directly to the implementation guides: - [Implementing TLS With Self-Signed Server Certificate](./implementing-tls-with-self-signed-server-certificate) - [Implementing TLS With Root CA-Signed Server Certificate](./implementing-tls-with-root-ca-signed-server-certificate.md) - [Implementing TLS With an Intermediate CA-signed Server Certificate](./implementing-tls-with-intermediate-ca-signed-server-certificate) - [Implementing Mutual TLS (mTLS)](./implementing-mutual-tls.md) ## Certificates and Public Key Infrastructure (PKI) SSL/TLS relies on a Public Key Infrastructure (PKI) to establish trust between parties. PKI is a framework that secures communication and verifies identities using digital certificates and public/private key pairs. A certificate is a digital document that binds a public key to an entity, such as a server or client. For example, an SSL certificate for the website "example.com" contains the site's public key and confirms that the domain name "example.com" is associated with it. When you visit the secured "example.com" website, your browser checks the site's certificate to ensure that it is valid and issued by a trusted Certificate Authority (CA). If the certificate is valid, your browser establishes a secure connection by generating a session key, encrypting it with the website's public key (extracted from the certificate), and securely sending it to the server. Only the server can decrypt the session key using its private key. From that point on, the browser and server use the session key to encrypt all data using fast symmetric encryption. A Certificate Authority (CA) is a trusted third party that issues digital certificates. It verifies the identity of the entity requesting the certificate and signs the certificate with its private key, creating a chain of trust. When your browser encounters a certificate signed by a CA, it checks whether the CA is in its list of trusted authorities. If it is, the browser trusts the certificate and establishes a secure connection. However, browsers have a limited number of trusted CAs built in. So what happens if the CA that issued a certificate is not pre-installed in the browser? This is where the concept of a **Certificate Chain** comes into play. It allows the browser to verify the certificate even if the issuing CA is not directly trusted. ### Understanding the Certificate Chain (Chain of Trust) A Certificate Chain, also known as a Chain of Trust, is a sequence of certificates that links your website's SSL certificate to a trusted root Certificate Authority (CA). When a browser encounters a certificate that is not directly trusted, it can follow the chain of trust to find a certificate that is trusted. To understand how this works, we have to learn about the different types of certificates involved in SSL/TLS communication and how they relate to each other in a hierarchical structure. There are three main types of certificates: - **End-entity certificates** are the certificates deployed on servers, applications, or devices to establish secure connections and prove identity. These contain the public key and identity information (like a domain name or email address) for the specific entity they represent. When you visit an HTTPS website, the SSL certificate presented is an end-entity certificate. These certificates sit at the bottom of the trust chain and cannot be used to sign other certificates; they only authenticate the final endpoint. - **Intermediate certificates** act as a bridge between root and end-entity certificates, forming the middle layer of the certificate chain. Signed by root CAs or other intermediates, they have authority to issue end-entity certificates or additional intermediate certificates. This delegation allows organizations to keep root certificates secure and offline while still managing day-to-day certificate issuance, creating a hierarchical structure that enables selective revocation without affecting the entire trust chain. - **Root certificates** are the foundational trust anchors of the PKI system, representing the highest authority level. These self-signed certificates contain the root CA's public key and are embedded directly into operating systems and browsers as pre-trusted entities. Root CAs keep their private keys in highly secure, offline environments and typically only sign intermediate certificates rather than end-entity certificates directly. All certificate validation ultimately traces back to these trusted roots. At the bottom of a certificate chain sits the **end-entity certificate** (leaf certificate), which contains the public key for a specific entity like a website and is signed by an intermediate CA. Above this are one or more **intermediate certificates** that bridge the gap between the end-entity and root. Each intermediate is signed by the certificate directly above it in the chain. At the top is the **root certificate**, self-signed by the root CA and pre-installed in browsers and operating systems as a trusted authority. This hierarchical structure creates a verifiable trust path from any certificate up to a universally trusted source. Think of the trust chain like a chain of personal recommendations. You might not know someone directly, but if your trusted friend vouches for their friend, who vouches for another person, you can trace that trust back to someone you know. Without this chain structure, your browser would need to personally "know" and trust every single website's certificate individually—that would mean storing millions of certificates. Instead, browsers only need to trust a small number of root certificates, and these roots can vouch for intermediates, which can vouch for many websites. This system also provides safety through isolation. If one intermediate certificate gets compromised, only the certificates it signed are affected—not every certificate in existence. It's like having multiple managers in a company instead of the CEO signing every document personally. ### How SSL/TLS Works in Practice Now, let's see how SSL/TLS works in practice when you visit a secure website. Think of certificates like digital ID cards for computers and websites, but with a clever twist involving special key pairs and a chain of trust. Every TLS-enabled website has two mathematically connected keys: - **Private Key:** Like a secret signature that only the website knows - kept completely secret - **Public Key:** Like a stamp of that signature that can be shared with everyone These keys have a special relationship: anything "signed" with the private key can be verified using the public key, but you can't figure out the private key from the public key. When you visit a website, here's what happens (please note that this is a simplified explanation): 1. **Website Shows Its Papers:** The website presents not just its own certificate, but the entire chain: - Its own certificate (signed by Intermediate CA) - The Intermediate CA's certificate (signed by Root CA) - Sometimes multiple intermediate certificates 2. **Browser Verifies the Chain:** Your browser works backwards up the chain: - "Is the website's certificate properly signed by the Intermediate CA?" ✓ - "Is the Intermediate CA's certificate properly signed by the Root CA?" ✓ - "Do I trust this Root CA?" (checks its built-in list) ✓ 3. **Identity Verification:** Once the chain is verified: - Browser says: "Prove you're really example.com" - The website uses its private key to sign a response - Browser uses the public key from the website's certificate to verify that signature - If it matches, the connection is secure The power of this approach is that your browser only needs to trust a few Root CAs, but through the chain of trust, it can verify millions of websites worldwide. ## One-Way TLS vs. Mutual TLS (mTLS) The process we've described so far - where your browser verifies a website's identity but the website doesn't verify yours - is called **one-way TLS** (also known as standard TLS or server-side authentication). This is what happens in most everyday web browsing scenarios. In one-way TLS: - **Only the server is authenticated** - the client (browser) verifies the server's identity using certificates - **The client remains anonymous** - the server has no cryptographic proof of who the client is - **The connection is still encrypted** - data flows securely in both directions, but only server identity is verified This works perfectly for most web applications where you want to ensure you're talking to the real bank website, but the bank doesn't need to cryptographically verify your identity upfront (they'll verify you through username/password or other means after the secure connection is established). ### What is Mutual TLS (mTLS)? Mutual TLS (mTLS) takes security a step further by requiring both parties to authenticate each other using certificates. Instead of just the server proving its identity to the client, the client must also prove its identity to the server using its own certificate. Think of it like police officers where: - **One-way TLS** is like when a police officer approaches you - they show their badge to prove they're legitimate law enforcement, and you verify the badge is real, but you don't need to show your ID back to them - **Mutual TLS (mTLS)** is like when two undercover officers meet - they both need to show each other their credentials and verify each other's legitimacy before proceeding with their operation ### How mTLS Works In an mTLS handshake, the process extends beyond standard TLS: 1. **Server Presents Its Certificate:** Just like in one-way TLS, the server presents its certificate chain to the client 2. **Client Verifies Server:** The client validates the server's certificate chain and identity (same as one-way TLS) 3. **Server Requests Client Certificate:** Here's where mTLS differs - the server asks the client to present its own certificate 4. **Client Presents Its Certificate:** The client sends its own certificate (and chain if applicable) to the server 5. **Server Verifies Client:** The server validates the client's certificate chain and identity 6. **Mutual Authentication Complete:** Both parties have cryptographically verified each other's identities ### When is mTLS Used? mTLS is typically employed in scenarios requiring high security and where both parties need to be certain of each other's identity: - **Microservices Architecture**: Secures service-to-service communication in distributed systems, particularly in containerized environments like Kubernetes where services need to verify each other's identity. - **API Security**: Protects high-value APIs handling financial transactions, personal data, or proprietary information by requiring cryptographic proof of client identity beyond simple API keys. - **IoT Device Management**: Authenticates devices before network access, preventing unauthorized devices from joining IoT deployments and protecting against device impersonation. - **Zero Trust Networks**: Serves as a foundational component requiring every connection to be authenticated and encrypted regardless of network location. - **Financial Services**: Enables secure interbank communications, payment gateway connections, and regulatory compliance for banks and fintech companies. - **Cloud Integration**: Provides secure authentication for cloud-to-cloud communications and hybrid architectures without relying on shared secrets. ### mTLS vs Other Authentication Methods It's important to understand how mTLS differs from other authentication approaches: - **mTLS vs Username/Password:** Unlike traditional username and password authentication, mTLS operates at the transport layer before any application data is exchanged. This fundamental difference means that certificates cannot be easily shared or compromised like passwords, eliminating the risk of common password-based attacks such as brute force attempts and credential stuffing. Additionally, mTLS works automatically without requiring user interaction once certificates are properly configured, providing seamless authentication. - **mTLS vs API Keys:** While API keys offer a simple authentication mechanism, mTLS provides significantly stronger cryptographic proof of identity through certificate-based authentication. Certificates come with built-in expiration and revocation mechanisms that provide better security lifecycle management. They are also much harder to accidentally leak since they aren't simple strings that can be inadvertently logged or exposed. However, unlike API keys, certificates cannot be easily rotated programmatically, which can be both an advantage for security and a disadvantage for operational flexibility. - **mTLS vs OAuth/JWT:** The key distinction here lies in the authentication layer where each method operates. mTLS authenticates at the connection level, while OAuth and JWT tokens work at the application level. This difference makes them complementary rather than competing technologies - mTLS can be effectively combined with OAuth to create layered security that ensures "the right device with the right user." It's important to note that mTLS focuses solely on authentication (verifying identity) and doesn't handle authorization (determining permissions), unlike OAuth which addresses both concerns. ## Standards, Encodings and File Formats When working with SSL/TLS, you'll encounter various standards, encodings, and file formats. Understanding these is crucial for effectively managing certificates and secure communication. In this series of articles, we use X.509 certificates, which are the most common format for SSL/TLS certificates. X.509 is a standard that defines the format of public key certificates, including the structure of the certificate itself and how it should be signed. Here are some of the key components of an X.509 certificate: 1. **Version**: Indicates the version of the X.509 standard used (e.g., v1, v2, v3). 2. **Serial Number**: A unique identifier assigned by the Certificate Authority (CA) to the certificate. 3. **Signature Algorithm**: Specifies the algorithm used to sign the certificate (e.g., SHA256 with RSA). 4. **Issuer**: Distinguished Name (DN) of the Certificate Authority (CA) that issued the certificate. 5. **Validity**: The time period during which the certificate is valid, including start and end dates. 6. **Subject**: Distinguished Name (DN) of the certificate holder (e.g., the server or organization the certificate represents). 7. **Subject Public Key Info**: Contains the public key of the certificate's subject and the algorithm used with that key. The following is an example of an X.509 certificate: ``` Certificate: Data: Version: 3 (0x2) Serial Number: 05:1b:2c:ce:d0:09:9e:28:6c:c6:6c:04:4a:4f:7e:c9:fb:06 Signature Algorithm: ecdsa-with-SHA384 Issuer: C=US, O=Let's Encrypt, CN=E5 Validity Not Before: Jun 2 15:35:11 2025 GMT Not After : Aug 31 15:35:10 2025 GMT Subject: CN=zio.dev Subject Public Key Info: Public Key Algorithm: id-ecPublicKey Public-Key: (256 bit) pub: 04:6d:d6:81:4c:da:b4:71: ASN1 OID: prime256v1 NIST CURVE: P-256 X509v3 extensions: X509v3 Key Usage: critical Digital Signature X509v3 Extended Key Usage: TLS Web Server Authentication, TLS Web Client Authentication X509v3 Basic Constraints: critical CA:FALSE X509v3 Subject Key Identifier: 47:C0:B2:73:CA:24:16:7C:62:59:37:CB:E7:2A:02:EA:73:30:CD:0B X509v3 Authority Key Identifier: 9F:2B:5F:CF:3C:21:4F:9D:04:B7:ED:2B:2C:C4:C6:70:8B:D2:D7:0D Authority Information Access: CA Issuers - URI:http://e5.i.lencr.org/ X509v3 Subject Alternative Name: DNS:*.zio.dev, DNS:zio.dev X509v3 Certificate Policies: Policy: 2.23.140.1.2.1 X509v3 CRL Distribution Points: Full Name: URI:http://e5.c.lencr.org/7.crl CT Precertificate SCTs: Signed Certificate Timestamp: Version : v1 (0x0) Log ID : 0D:E1:F2:30:2B:D3:0D:C1:40:62:12:09:EA:55:2E:FC: 47:74:7C:B1:D7:E9:30:EF:0E:42:1E:B4:7E:4E:AA:34 Timestamp : Jun 2 16:33:41.242 2025 GMT Extensions: none Signature : ecdsa-with-SHA256 30:45:02:21:00:AF:62:38:A3:6C:F6:11:05:77:46:ED: C0:34:3E:44:50:43:24:67:83:B8:62:7B:DE:7C:F1:39: 16:6F:B2:D2:3D:02:20:46:0B:EA:A5:36:E0:80:99:2E: B8:E2:8D:97:5F:FA:1B:95:16:8C:F3:88:52:C5:17:E9: 96:14:8A:74:81:CC:7E Signed Certificate Timestamp: Version : v1 (0x0) Log ID : CC:FB:0F:6A:85:71:09:65:FE:95:9B:53:CE:E9:B2:7C: 22:E9:85:5C:0D:97:8D:B6:A9:7E:54:C0:FE:4C:0D:B0 Timestamp : Jun 2 16:33:43.251 2025 GMT Extensions: none Signature : ecdsa-with-SHA256 30:45:02:21:00:C1:04:E6:A8:65:FE:5B:D6:DF:17:27: BF:BC:FB:16:B6:A0:D1:03:14:46:AA:01:92:45:83:5E: A6:0C:00:31:7B:02:20:2F:3D:2D:18:5C:F8:0A:02:B1: 62:F7:38:B2:E9:08:7F:04:C6:05:76:E4:26:FD:C5:81: D7:33:20:FD:F4:65:73 Signature Algorithm: ecdsa-with-SHA384 Signature Value: 30:64:02:30:1d:c0:d3:dc:3f:fd:ef:54:d8:1c:28:05:57:36: 05:de:a6:83:7e:a3:5e:ee:54:7e:5c:09:44:91:4a:45:c0:16: 07:d3:d7:e9:cc:fe:83:0d:63:49:7f:75:e1:3b:2f:9e:02:30: 52:f8:7c:67:85:0d:3a:d9:80:df:74:3b:67:36:89:81:19:2f: 3e:50:62:9c:89:0b:9b:7e:52:93:ea:a2:01:54:85:02:18:2c: 8a:6f:19:c2:8d:13:96:11:27:bf:f3:a4 ``` Each certificate can be encoded in different formats, with the most common being: - **PEM (Privacy-Enhanced Mail)**: Base64 encoded format with header and footer lines (e.g., `-----BEGIN CERTIFICATE-----` and `-----END CERTIFICATE-----`). This is the most widely used format for SSL/TLS certificates. Here is an example PEM file: ``` -----BEGIN CERTIFICATE----- MIIDdTCCAl2gAwIBAgILBAAAAAABFUtaw5QwDQYJKoZIhvcNAQEFBQAwVzELMAkG A1UEBhMCQkUxGTAXBgNVBAoTEEdsb2JhbFNpZ24gbnYtc2ExEDAOBgNVBAsTB1Jv ... (more base64 data) ... -----END CERTIFICATE----- ``` - **DER (Distinguished Encoding Rules)**: Binary format that is not human-readable. It is often used in Java applications and some Windows systems. Certificates are usually stored in files with the following common file extensions: - **.crt** - Most common, usually PEM format - **.cer** - Can be DER or PEM format - **.pem** - Always PEM format - **.der** - Always DER (binary) format Besides the PEM and DER formats, you may also encounter envelope formats, such as PKCS#12 and PKCS#7: - **PKCS#12 (.p12 or .pfx)**: A binary format that can contain both the certificate and its private key, often used for importing/exporting certificates with their private keys. It is password-protected to secure the private key. These are used when we need to bundle the private key and certificate. - **PKCS#7 (.p7b or .p7c)**: A format that can contain multiple certificates (like a certificate chain) but does not include the private key. It is often used for distributing certificates and building certificate trust chains. We may also store public keys in a file with a `.pub` or `.pem` extension and store private keys in files with `.prv`, `.key` or `.pem` extension. There are more formats and file extensions, but for the purpose of this guide, we will focus on the most commonly used formats: PEM for certificates and private keys and PKCS#12 for bundles containing both certificates and private keys. [//]: # (Certificates are public information, and they can be stored in a plain text file without a need for permission or password protection. However, the private key associated with a certificate must be kept secure and is typically stored in a separate file with restricted access.) Now that we have covered the most basic concepts of SSL/TLS, certificates, and PKI, we can proceed to the implementation articles where we will explore how to set up SSL/TLS in ZIO HTTP applications using different approaches, including self-signed certificates, CA-signed certificates, intermediate CA-signed certificates, and mutual TLS (mTLS). ## Conclusion SSL/TLS and PKI form the foundation of secure internet communication. The key concepts covered—certificate chains, trust hierarchies, and the distinction between one-way TLS and mutual TLS—provide the knowledge needed to make informed security decisions for your applications. PKI's hierarchical design allows a small number of trusted root CAs to secure millions of websites through intermediate certificates, creating a scalable trust system that spans from internal applications to global services. With these fundamentals in place, you're ready to proceed to the implementation guides. Each approach serves specific use cases, and understanding their proper application will help you build more secure applications. --- ## Testing HTTP Applications ## Introduction Testing HTTP applications is fundamentally different from testing regular Scala libraries — you're testing code that handles network I/O, manages stateful connections, and deals with concurrent requests. This guide shows you how to write fast, reliable tests for ZIO HTTP applications without the typical overhead and complexity of HTTP testing. The key insight is that **ZIO HTTP treats routes as pure functions**: a route is simply `Request => ZIO[R, Response, Response]`. This functional model means you can test your HTTP logic the same way you test any other ZIO effect — by providing inputs and asserting on outputs — without needing to start a server, manage ports, or deal with network I/O. By the end of this guide, you'll understand: - How to test routes directly without any infrastructure - How to use `TestClient` to mock HTTP client behavior - How to use `TestServer` for integration testing multiple routes - How to test stateful handlers that maintain request context - How to test WebSocket connections with bidirectional messaging - How to verify error handling and edge cases ## The Problem with Traditional HTTP Testing Before ZIO HTTP, testing an HTTP service typically involved one of these painful approaches: **Starting a real server for each test**: Your test suite starts a Netty server on a random port, makes HTTP requests to it, then shuts it down. This works, but is slow (seconds per test), hard to debug (now you have network I/O to reason about), and makes it difficult to test edge cases (how do you simulate a network timeout? A broken connection?). **Mocking everything at the HTTP library level**: You might mock the underlying `Client` or `Server` classes, but then you're not really testing your HTTP logic — you're testing that your code calls the mocked object correctly. If the mock doesn't match the real behavior, your tests pass but your code fails in production. **Writing integration tests that depend on external services**: Your tests talk to a real database, a real cache, a real message queue. Now your tests are slow, flaky (external services go down), and hard to run locally. You can't easily test error paths like "what if the database is unavailable?" ZIO HTTP solves this by treating routes as **pure, testable functions** that you can invoke directly in your tests without any server infrastructure. ## Prerequisites and Setup To follow this guide, add these test dependencies to your `build.sbt`: ```scala libraryDependencies ++= Seq( "dev.zio" %% "zio-test" % "2.1.26" % Test, "dev.zio" %% "zio-test-sbt" % "2.1.26" % Test, "dev.zio" %% "zio-http-testkit" % "3.8.1" % Test, ) testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") ``` This guide assumes you're familiar with: - [ZIO effects and testing](https://zio.dev/reference/test/) — how to write ZIO code and tests - [ZIO HTTP routes and handlers](../reference/routing/routes.md) — how to define HTTP endpoints - Basic HTTP concepts — requests, responses, status codes, headers If you're new to ZIO or ZIO HTTP, start with those reference pages before diving into testing patterns. ## Three Testing Patterns ZIO HTTP provides three distinct testing patterns, each suited to different scenarios: 1. **Direct Route Testing**: Test a handler directly by converting it to routes and calling `runZIO(request)`. This is the simplest approach and ideal for unit testing individual handlers in isolation. 2. **TestClient**: Mock the HTTP client by defining what responses to return for specific requests. Use when your application makes HTTP calls to external services and you want to mock those dependencies. 3. **TestServer**: Start a test server that responds to HTTP requests based on your routes. Use for integration testing multiple routes working together, or when testing code that makes HTTP requests and you want to verify the exact requests being made. Each pattern serves a different testing need — we'll explore them in order of complexity and show when to use each one. ## Pattern 1: Direct Route Testing The simplest and fastest way to test is to invoke a handler directly as a function, without any server infrastructure. In ZIO HTTP, a `Handler` is a function that takes a `Request` and returns a `ZIO` effect that produces a `Response`. You can convert a handler to a `Routes` object using `.toRoutes`, then call `runZIO` directly in your test to invoke the routing logic. This approach is ideal when you're testing a single handler in isolation — for example, a handler that parses JSON, validates input, and returns a response. There's no networking, no port binding, no concurrent connections to worry about. Just pure ZIO effects. **When to use this pattern:** - Unit testing individual handlers or middleware - Testing request parsing and validation logic - Testing simple routes with no external dependencies - Fast feedback loop during development **When NOT to use this pattern:** - Your code needs to handle multiple routes with different path patterns - You need to test that your code makes HTTP calls to other services - You're testing middleware or request/response transformations that depend on route context Here's a simple example: ```scala title="zio-http-example-testing/src/test/scala/example/testing/DirectRouteExampleSpec.scala" package example.testing /** * Testing HTTP Applications — Pattern 1: Direct Route Testing * * Demonstrates how to test routes directly without any server infrastructure * by calling runZIO on a route with a request and asserting on the response. * * Run with: sbt "zio-http-example-testing/testOnly example.testing.DirectRouteExampleSpec" */ object DirectRouteExampleSpec extends ZIOSpecDefault { def spec = test("handler returns OK status") { // Create a simple handler that always returns OK val handler = Handler.ok.toRoutes // Call the route directly with a request val request = Request.get(URL(Path.root)) // Assert the response status for { response <- handler.runZIO(request) } yield assertTrue(response.status == Status.Ok) } } ``` ([source](https://github.com/zio/zio-http/blob/main/zio-http-example-testing/src/test/scala/example/testing/DirectRouteExampleSpec.scala)) :::note[Example Source Code] The code examples below are linked directly from the ZIO HTTP test suite and can be run as-is. You can find, study, and execute these tests yourself in the repository. ::: The key here is `handler.toRoutes.runZIO(request)` — first, the handler is converted to a `Routes` object using `.toRoutes`, then `runZIO` invokes the route function directly and returns the response as a ZIO effect, which you then assert on. **Behind the scenes:** A `Routes` object is a function that pattern-matches on the request path and method, then invokes the appropriate handler. By calling `runZIO` directly on the routes, you skip the server entirely and invoke the routing logic as a pure function. The handler must first be converted to routes using `.toRoutes` before you can call `runZIO` on it. ## Pattern 2: TestClient — Mock HTTP Dependencies `TestClient` is a mock HTTP client implementation. Instead of making real HTTP requests to external services, your code makes requests to the `TestClient`, which you've configured to return specific responses. **What is TestClient useful for?** Imagine your application calls an external service — maybe a payment processor, a weather API, or a recommendation engine. In production, these calls go over the network to real servers. But in tests, you don't want to: - Make real requests to external APIs (slow, unreliable, might incur costs) - Depend on those services being up and available - Test against production data or state Instead, you use `TestClient` to provide a mock implementation of the `Client` interface. Your code calls the mock client with requests, and the mock returns responses you've configured. **How TestClient works:** You provide the `TestClient.layer` in your test, which gives your code a `Client` instance. Instead of the real network-based client, it's a test implementation. Then you configure what responses to return for what requests using methods like `addRoute`, `addRequestResponse`, and `setFallbackHandler`. **Simple request/response mappings:** For straightforward cases where a specific request should always get a specific response, use `addRequestResponse`: ```scala title="zio-http-example-testing/src/test/scala/example/testing/GuideTestClientBasicSpec.scala" package example.testing /** * Testing HTTP Applications — Pattern 2: TestClient - Mock HTTP Dependencies * * Demonstrates how to mock a simple API endpoint using TestClient.addRequestResponse, * which returns a specific response for an exact request. * * Run with: sbt "zio-http-example-testing/testOnly example.testing.GuideTestClientBasicSpec" */ object GuideTestClientBasicSpec extends ZIOSpecDefault { def spec = test("mock a simple API endpoint") { for { client <- ZIO.service[Client] // Configure the mock: when this exact request is made, return this response _ <- TestClient.addRequestResponse( Request.get(URL.root / "users" / "1"), Response.json("""{"id": 1, "name": "Alice", "email": "alice@example.com"}""") ) // Your code calls the client with the request response <- client(Request.get(URL.root / "users" / "1")) body <- response.body.asString } yield assertTrue(body.contains("Alice")) }.provide(TestClient.layer, Scope.default) } ``` ([source](https://github.com/zio/zio-http/blob/main/zio-http-example-testing/src/test/scala/example/testing/GuideTestClientBasicSpec.scala)) The mock checks that the incoming request matches exactly — same method, same URL, same headers. If your code makes a different request, it throws an error. This strictness is actually a feature: it forces your test to be specific about what requests should be made. **Flexible route handlers:** Sometimes you need more flexibility. Maybe you want to handle a range of requests (e.g., `GET /users/{id}` for any ID), or perform some computation before returning the response. Use `addRoute`: ```scala title="zio-http-example-testing/src/test/scala/example/testing/GuideTestClientFlexibleSpec.scala" package example.testing /** * Testing HTTP Applications — Pattern 2: TestClient with Dynamic Handler * * Demonstrates how to use TestClient.addRoute with a flexible handler that * processes path parameters dynamically instead of exact request matching. * * Run with: sbt "zio-http-example-testing/testOnly example.testing.GuideTestClientFlexibleSpec" */ object GuideTestClientFlexibleSpec extends ZIOSpecDefault { def spec = test("mock with dynamic handler") { for { client <- ZIO.service[Client] // Handler receives the path parameter and can use it _ <- TestClient.addRoute { Method.GET / "users" / int("id") -> handler { (id: Int, _: Request) => ZIO.succeed(Response.json(s"""{"id": $id, "name": "User $id"}""")) } } // Now any GET /users/{id} request will be handled by this handler response1 <- client(Request.get(URL.root / "users" / "1")) body1 <- response1.body.asString response2 <- client(Request.get(URL.root / "users" / "99")) body2 <- response2.body.asString } yield assertTrue( body1.contains("User 1"), body2.contains("User 99") ) }.provide(TestClient.layer, Scope.default) } ``` ([source](https://github.com/zio/zio-http/blob/main/zio-http-example-testing/src/test/scala/example/testing/GuideTestClientFlexibleSpec.scala)) This is more flexible — the handler function lets you compute the response dynamically based on the request content. **Catching unexpected requests with fallback handlers:** A powerful testing technique is to track what requests your code makes. Use `setFallbackHandler` to see any requests that don't match your configured routes: ```scala title="zio-http-example-testing/src/test/scala/example/testing/GuideFallbackHandlerSpec.scala" package example.testing /** * Testing HTTP Applications — Pattern 2: TestClient with Fallback Handler * * Demonstrates how to use TestClient.setFallbackHandler to track unexpected requests * and verify that your code only makes expected HTTP calls. * * Run with: sbt "zio-http-example-testing/testOnly example.testing.GuideFallbackHandlerSpec" */ object GuideFallbackHandlerSpec extends ZIOSpecDefault { def spec = test("verify expected requests are made") { for { client <- ZIO.service[Client] // Track unexpected requests unexpectedCalls <- Ref.make[List[Request]](Nil) _ <- TestClient.setFallbackHandler { req => unexpectedCalls.update(_ :+ req).as(Response.notFound) } // Configure expected route _ <- TestClient.addRoute { Method.GET / "expected" -> handler(Response.ok) } // Your code makes some requests _ <- client(Request.get(URL.root / "expected")) _ <- client(Request.get(URL.root / "unexpected")) // Verify what was called unexpectedRequests <- unexpectedCalls.get } yield assertTrue( unexpectedRequests.length == 1, unexpectedRequests.head.url.path.encode == "/unexpected" ) }.provide(TestClient.layer, Scope.default) } ``` ([source](https://github.com/zio/zio-http/blob/main/zio-http-example-testing/src/test/scala/example/testing/GuideFallbackHandlerSpec.scala)) This pattern is especially useful when testing code that makes HTTP calls to multiple services. You can verify that: 1. The expected calls are made with the right parameters 2. No unexpected calls are made to unrelated services 3. The code handles specific error responses correctly :::note[Inverted Dependency Model] TestClient is an **inverted dependency model**: instead of mocking the Client interface itself (which is brittle), you configure TestClient routes just like you would configure real server routes. Your code gets a Client that works exactly like the real one, but backed by your test configuration. This makes tests more realistic and resistant to changes in the Client implementation. ::: ## Pattern 3: TestServer — Integration Testing Use `TestServer` when you need to test your HTTP application as a server responding to requests. Unlike `TestClient` which mocks the outbound client, `TestServer` is the inbound side — it receives HTTP requests and returns responses. **What is TestServer useful for?** TestServer is ideal when: - You're testing multiple routes working together - Your routes depend on each other (e.g., creating a resource returns an ID you use in subsequent requests) - You're testing middleware that applies to all routes - You want integration tests that exercise the full request/response cycle without network I/O TestServer binds to a localhost port and uses loopback network I/O, making it fast and deterministic without the latency of remote network calls. You make HTTP requests using the standard `Client` interface, which creates a realistic testing scenario without incurring actual network overhead. **Basic server setup:** The simplest TestServer test configures one or more routes, then uses a `Client` to make HTTP requests to the server: ```scala title="zio-http-example-testing/src/test/scala/example/testing/GuideTestServerBasicSpec.scala" package example.testing /** * Testing HTTP Applications — Pattern 3: TestServer - Integration Testing * * Demonstrates how to use TestServer to test routes by making HTTP requests * to an in-memory test server using a standard Client. * * Run with: sbt "zio-http-example-testing/testOnly example.testing.GuideTestServerBasicSpec" */ object GuideTestServerBasicSpec extends ZIOSpecDefault { def spec = test("server responds to requests") { for { client <- ZIO.service[Client] // Get the port the test server is bound to port <- ZIO.serviceWithZIO[Server](_.port) // Configure a route _ <- TestServer.addRoute { Method.GET / "hello" -> Handler.text("Hello, World!") } // Make a request to localhost:port response <- client(Request.get(URL.root.port(port) / "hello")) body <- response.body.asString } yield assertTrue(body == "Hello, World!") }.provide(TestServer.default, Client.default, Scope.default) } ``` ([source](https://github.com/zio/zio-http/blob/main/zio-http-example-testing/src/test/scala/example/testing/GuideTestServerBasicSpec.scala)) The key difference from direct route testing: here we're actually making HTTP requests to the server via the `Client`, just like production code would. The `TestServer` receives those requests, matches them against configured routes, and returns responses. **Testing multiple routes together:** TestServer shines when testing how multiple routes work together: ```scala title="zio-http-example-testing/src/test/scala/example/testing/GuideMultiRouteIntegrationSpec.scala" package example.testing /** * Testing HTTP Applications — Pattern 3: TestServer with Multiple Routes * * Demonstrates how to use TestServer to test multiple routes working together * with route precedence (specific routes before fallback routes). * * Run with: sbt "zio-http-example-testing/testOnly example.testing.GuideMultiRouteIntegrationSpec" */ object GuideMultiRouteIntegrationSpec extends ZIOSpecDefault { def spec = test("test interactions between routes") { for { client <- ZIO.service[Client] port <- ZIO.serviceWithZIO[Server](_.port) // Configure both a specific route and a fallback _ <- TestServer.addRoutes { Routes( // Specific route: GET /items/{id} Method.GET / "items" / int("id") -> handler { (id: Int, _: Request) => ZIO.succeed(Response.json(s"""{"type": "specific", "id": $id}""")) }, // Fallback: any other GET request Method.GET / trailing -> handler { (_: Request) => ZIO.succeed(Response.json("""{"type": "fallback"}""")) }, ) } // Request the specific route specificResp <- client(Request.get(URL.root.port(port) / "items" / "42")) specificBody <- specificResp.body.asString // Request a path that matches the fallback fallbackResp <- client(Request.get(URL.root.port(port) / "other")) fallbackBody <- fallbackResp.body.asString } yield assertTrue( specificBody.contains("specific"), fallbackBody.contains("fallback"), ) }.provide(TestServer.default, Client.default, Scope.default) } ``` ([source](https://github.com/zio/zio-http/blob/main/zio-http-example-testing/src/test/scala/example/testing/GuideMultiRouteIntegrationSpec.scala)) Notice that routes are evaluated in order — the specific `GET /users/{id}` route is checked first, and if it doesn't match, the fallback `GET /...` route is checked. This mirrors production behavior and lets you test route precedence. **Key differences from TestClient:** - **TestClient** mocks the outbound `Client` — use when your code makes HTTP calls to external services - **TestServer** mocks the inbound `Server` — use when testing code that receives HTTP requests - **TestServer is more like production** — you make real HTTP requests that go through the full routing logic - **TestServer is heavier** — it binds to a port and runs routing, so it's slower than direct route testing ## Testing Stateful Handlers Many real-world handlers maintain state across requests. For example: - An authentication handler tracks login attempts to prevent brute force attacks - A rate limiting middleware tracks how many requests each user makes - A caching handler caches values to avoid recomputing them - A checkout handler maintains a shopping cart across multiple requests When testing such handlers, you need to verify that state is correctly maintained as requests arrive. **How to test state:** Use a `Ref` (ZIO's mutable reference) to hold state that your handler can modify. The key is to make multiple requests and verify that the state changes correctly based on each request: ```scala title="zio-http-example-testing/src/test/scala/example/testing/GuideStatefulHandlerSpec.scala" package example.testing /** * Testing HTTP Applications — Testing Stateful Handlers * * Demonstrates how to test handlers that maintain state across multiple requests * using Ref to track and verify state changes. * * Run with: sbt "zio-http-example-testing/testOnly example.testing.GuideStatefulHandlerSpec" */ object GuideStatefulHandlerSpec extends ZIOSpecDefault { def spec = test("handler state persists across requests") { for { client <- ZIO.service[Client] port <- ZIO.serviceWithZIO[Server](_.port) // Create a Ref to hold a counter counter <- Ref.make(0) // Configure a route that increments the counter _ <- TestServer.addRoute { Method.GET / "increment" -> handler { (_: Request) => counter.updateAndGet(_ + 1).map(n => Response.text(s"Count: $n")) } } // Make first request resp1 <- client(Request.get(URL.root.port(port) / "increment")) body1 <- resp1.body.asString // Make second request resp2 <- client(Request.get(URL.root.port(port) / "increment")) body2 <- resp2.body.asString // Make third request resp3 <- client(Request.get(URL.root.port(port) / "increment")) body3 <- resp3.body.asString } yield assertTrue( body1 == "Count: 1", body2 == "Count: 2", body3 == "Count: 3", ) }.provide(TestServer.default, Client.default, Scope.default) } ``` ([source](https://github.com/zio/zio-http/blob/main/zio-http-example-testing/src/test/scala/example/testing/GuideStatefulHandlerSpec.scala)) **Why is this important?** Stateful handler testing reveals bugs that wouldn't be caught by testing a single request: - State might not be properly reset between requests - State updates might race (in concurrent scenarios) - State might leak between different users or sessions - Recovery logic might not work when state is corrupted By making multiple requests in sequence and verifying the state changes at each step, you ensure your stateful logic is correct. :::note[Modeling Application State] Use `Ref` for shared mutable state that handlers need to access. The test creates the `Ref` once, then handlers update it across multiple requests. This models how real applications maintain state — think of it as an in-memory database that all handlers have access to. ::: ## Testing WebSocket Connections WebSockets are fundamentally different from HTTP: instead of request/response pairs, WebSocket connections are long-lived, bidirectional channels where either side can send messages at any time. **How WebSocket testing works:** Testing WebSockets is tricky because you need to test two sides of a conversation simultaneously: 1. The **server handler** — receives messages from clients and sends responses 2. The **client handler** — sends messages to the server and receives responses ZIO HTTP provides `TestChannel` to make this work. A `TestChannel` is an in-memory, bidirectional message queue. You configure both a server handler and a client handler, then they communicate through the test channel — no actual network involved. **Understanding the pairing model:** When you call `client.socket(socketApp)`, the client sends a WebSocket upgrade request. TestClient intercepts this and: 1. Creates a `TestChannel` with two ends: one for the server, one for the client 2. Runs the server handler on one end 3. Runs the client app on the other end 4. Messages sent by the client appear in the server's input queue 5. Messages sent by the server appear in the client's input queue **A simple echo server example:** To create a WebSocket handler that echoes messages back to clients: ```scala title="zio-http-example-testing/src/test/scala/example/testing/GuideWebSocketEchoSpec.scala" package example.testing /** * Testing HTTP Applications — Testing WebSocket Connections * * Demonstrates how to test WebSocket bidirectional communication using TestChannel * with a simple echo server and client pattern. * * Run with: sbt "zio-http-example-testing/testOnly example.testing.GuideWebSocketEchoSpec" */ object GuideWebSocketEchoSpec extends ZIOSpecDefault { def spec = test("echo server echoes messages") { // The server receives messages and echoes them back val echoServer: WebSocketApp[Any] = Handler.webSocket { channel => channel.receiveAll { case Read(WebSocketFrame.Text(message)) => // When we receive text, send it back with "Echo: " prefix channel.send(Read(WebSocketFrame.text(s"Echo: $message"))) case _ => ZIO.unit } } for { // Use a Promise to coordinate between the forked client fiber and this test receivedFrame <- Promise.make[Throwable, WebSocketFrame] // The client sends a message and expects to receive the echo testClient: WebSocketApp[Any] = Handler.webSocket { channel => for { // Skip handshake complete event _ <- channel.receive // Send a message _ <- channel.send(Read(WebSocketFrame.text("Hello, Server!"))) // Wait to receive the response response <- channel.receive // Signal the received frame to the outer test _ <- response match { case Read(frame) => receivedFrame.succeed(frame) case _ => receivedFrame.fail(new Exception("Expected ChannelEvent.Read")) } _ <- channel.shutdown } yield () } // Install the server handler in TestClient _ <- TestClient.installSocketApp(echoServer) // The client calls socket() with its handler _ <- ZIO.serviceWithZIO[Client](_.socket(testClient)) // Wait for the frame and verify it matches the expected echo frame <- receivedFrame.await } yield assertTrue( frame match { case WebSocketFrame.Text(msg) => msg == "Echo: Hello, Server!" case _ => false } ) }.provide(TestClient.layer, Scope.default) } ``` ([source](https://github.com/zio/zio-http/blob/main/zio-http-example-testing/src/test/scala/example/testing/GuideWebSocketEchoSpec.scala)) **Key concepts:** - **`Handler.webSocket { channel => ... }`** — Creates a WebSocket handler that operates on a `WebSocketChannel`. The handler runs as a ZIO effect and can send and receive messages. - **`channel.receive`** — Waits for the next message from the other side - **`channel.receiveAll { case ... => ... }`** — Pattern matches on incoming messages and responds to each one - **`channel.send(...)`** — Sends a message to the other side - **`Read(WebSocketFrame.Text(...))`** — Wraps a text message in a `ChannelEvent.Read` so it can be sent/received **The handshake:** When you upgrade to WebSocket, both sides automatically receive a `HandshakeComplete` event as the first message. This signals that the upgrade succeeded and bidirectional communication can begin. You must consume this event before processing application messages — as shown in the example above where we skip it with `_ <- channel.receive` before entering the echo loop. :::warning WebSocket handlers run concurrently. Both the server and client handlers are running at the same time, each waiting to receive or send messages. Be careful about deadlocks — for example, if both sides wait to receive without sending first, they'll hang indefinitely. ::: ## Testing Error Scenarios and Edge Cases A critical part of testing is verifying that your application handles errors correctly. This includes: - **HTTP errors** — returning the right status codes (401, 404, 500, etc.) - **Input validation** — rejecting invalid requests with helpful error messages - **Fault tolerance** — handling timeouts, network errors, and other failures - **State recovery** — cleaning up properly when things go wrong **Why error testing matters:** It's easy to test the "happy path" where everything works correctly. But in production, things fail — users send invalid data, external services time out, databases go down. Your code needs to handle these cases gracefully. Testing error scenarios ensures: - Users get appropriate error messages (not a confusing 500 error) - Error state doesn't leak to subsequent requests - Security isn't compromised when validating input - Failures are logged properly for debugging **Testing HTTP status codes:** Use direct route testing or `TestServer` to verify that your handlers return the correct status codes: ```scala title="zio-http-example-testing/src/test/scala/example/testing/GuideErrorHandlingSpec.scala" package example.testing /** * Testing HTTP Applications — Testing Error Scenarios * * Demonstrates how to test HTTP error responses including status codes * like 401 (Unauthorized) and 400 (Bad Request). * * Run with: sbt "zio-http-example-testing/testOnly example.testing.GuideErrorHandlingSpec" */ object GuideErrorHandlingSpec extends ZIOSpecDefault { def spec = suite("error responses")( test("unauthorized when missing auth header") { for { client <- ZIO.service[Client] port <- ZIO.serviceWithZIO[Server](_.port) // Configure a protected endpoint _ <- TestServer.addRoute { Method.GET / "protected" -> handler { (req: Request) => if (req.headers.contains("Authorization")) ZIO.succeed(Response.ok) else ZIO.succeed( Response.status(Status.Unauthorized) .addHeader("WWW-Authenticate", "Bearer realm=\"api\"") ) } } response <- client(Request.get(URL.root.port(port) / "protected")) } yield assertTrue(response.status == Status.Unauthorized) }, test("bad request when input is invalid") { for { client <- ZIO.service[Client] port <- ZIO.serviceWithZIO[Server](_.port) _ <- TestServer.addRoute { Method.POST / "users" -> handler { (_: Request) => ZIO.succeed( Response.status(Status.BadRequest) .addHeader("X-Error", "Missing required field") ) } } response <- client(Request.post(URL.root.port(port) / "users", Body.empty)) } yield assertTrue(response.status == Status.BadRequest) }, ).provide(TestServer.default, Client.default, Scope.default) } ``` ([source](https://github.com/zio/zio-http/blob/main/zio-http-example-testing/src/test/scala/example/testing/GuideErrorHandlingSpec.scala)) **Testing error messages:** Beyond just the status code, verify that error messages are clear and helpful: ```scala title="zio-http-example-testing/src/test/scala/example/testing/GuideValidationErrorSpec.scala" package example.testing /** * Testing HTTP Applications — Testing Error Messages * * Demonstrates how to test that error responses include helpful messages * by examining response headers and status codes together. * * Run with: sbt "zio-http-example-testing/testOnly example.testing.GuideValidationErrorSpec" */ object GuideValidationErrorSpec extends ZIOSpecDefault { def spec = test("error response includes helpful message") { for { client <- ZIO.service[Client] port <- ZIO.serviceWithZIO[Server](_.port) _ <- TestServer.addRoute { Method.POST / "login" -> handler { (req: Request) => req.body.asString .map { body => if (body.isEmpty) Response.status(Status.BadRequest) .addHeader("X-Error", "Username and password required") else Response.ok } .catchAll { _ => ZIO.succeed(Response.status(Status.BadRequest)) } } } response <- client(Request.post(URL.root.port(port) / "login", Body.empty)) errorMsg = response.headers.get("X-Error") } yield assertTrue( response.status == Status.BadRequest, errorMsg.exists(_.contains("required")) ) }.provide(TestServer.default, Client.default, Scope.default) } ``` ([source](https://github.com/zio/zio-http/blob/main/zio-http-example-testing/src/test/scala/example/testing/GuideValidationErrorSpec.scala)) :::note[Error Testing Pays Off] Error testing is where the investment in testing really pays off. A single unhandled error path in production can impact thousands of users. By testing error scenarios systematically, you gain confidence that your application degrades gracefully under failure. ::: ## Choosing the Right Testing Approach Now that you understand the three patterns, how do you decide which to use? **Use direct route testing when:** - You're unit testing a single handler or middleware in isolation - You want the fastest possible feedback loop during development - You don't need to test multiple routes or external dependencies - Example: Testing a JSON parser, validation logic, or simple request transformation **Use TestClient when:** - Your code makes HTTP calls to external services (databases, APIs, message queues, etc.) - You want to mock those dependencies and test failure scenarios - You need to verify that your code makes the right HTTP calls with the right parameters - Example: Testing a client that calls a payment processor, weather API, or recommendation service **Use TestServer when:** - You're testing multiple routes working together - Your routes depend on each other (e.g., create → read → update → delete flows) - You want to test middleware, authentication, or request routing logic - You're doing integration testing of larger features - Example: Testing a complete user management API with create, read, update, delete endpoints **In practice, use all three:** A well-tested application uses a mixture: - **Unit tests** with direct route testing for individual handlers - **Mock tests** with TestClient to verify integration with external services - **Integration tests** with TestServer to test feature workflows This pyramid approach gives you fast feedback from unit tests, confidence that external integrations work from mock tests, and assurance that features work end-to-end from integration tests. ## Best Practices for HTTP Testing **1. Test both success and failure paths** For every feature, write tests for: - The happy path (everything works) - Common failure cases (invalid input, missing data, etc.) - Rare but serious failures (database unavailable, timeout, etc.) **2. Test at the right level** Don't test everything with TestServer. Use direct route testing for unit tests, reserve TestServer for features that truly need it. This keeps your test suite fast. **3. Use meaningful test names** Instead of `test("works")`, write `test("returns 401 when authorization header is missing")`. A good test name documents what you're testing. **4. Test behavior, not implementation** Test that the handler returns the right status code and response body. Don't test internal implementation details like "this variable was set correctly". **5. Verify the whole request/response cycle** Don't just check the status code. Verify: - Status code is correct - Response headers are correct - Response body has the expected content - Side effects happened (e.g., data was persisted) This gives you confidence that the whole feature works, not just parts of it. ## Going Deeper Once you're comfortable with the basics, consider these advanced testing techniques: **Testing Middleware** Middleware runs on every request. Test it by wrapping your routes with middleware and verifying that it transforms requests/responses correctly. For example, test that an authentication middleware rejects requests without credentials, or that a logging middleware doesn't interfere with response bodies. **Testing Concurrent Behavior** ZIO HTTP handlers can run concurrently. Use `TestServer` with multiple concurrent requests to test: - Race conditions in stateful handlers - Connection pooling behavior - Concurrent request limits **Integration Testing with Real Resources** For end-to-end testing, combine TestServer with real resources: - Real database connections (but use transactions to roll back) - Real message queues (but use test instances) - Real caches (but reset state between tests) This gives you confidence that the whole system works together. **Performance Testing** Use TestClient to simulate load: - Make thousands of requests and measure latency - Test that handlers scale well under load - Verify that resource cleanup happens correctly **Error Recovery Testing** Test that your handlers recover correctly from failures: - Simulate database timeouts with slow test routes - Test that connection pools recover after failures - Verify that retries happen with correct backoff ## Summary ZIO HTTP's functional model makes testing straightforward: - **Direct route testing** for fast unit tests - **TestClient** for mocking external dependencies - **TestServer** for integration testing features - All three can run without network I/O, making tests fast and reliable The key insight is that routes are just functions — you can test them like any other ZIO effect, which makes HTTP testing simple and deterministic. For more details, see: - [ZIO Test documentation](https://zio.dev/reference/test/) — how to write ZIO tests - [ZIO HTTP Handler reference](../reference/handler.md) — handler patterns and APIs - [ZIO HTTP Route reference](../reference/routing/routes.md) — route definition and matching --- ## Securing Your APIs: Passwordless Authentication with WebAuthn Passwords have long been the cornerstone of online authentication, but they come with significant drawbacks. Users often struggle to create and remember strong passwords, leading to weak security practices such as password reuse and susceptibility to phishing attacks. To address these challenges, the industry is shifting toward passwordless authentication—a model that enhances both security and user experience by eliminating passwords altogether. At the forefront of this movement are WebAuthn (Web Authentication API) and the CTAP2 standard (Client to Authenticator Protocol 2) under the FIDO2 umbrella. These technologies leverage public-key cryptography and device-based authenticators to provide a robust, phishing-resistant authentication mechanism. WebAuthn is a web standard published by the World Wide Web Consortium (W3C) that enables web clients to communicate with authenticators. CTAP2 defines how a client (like your PC, phone, or browser) communicates with an authenticator. Together, they form the foundation of FIDO2, which enables us to move beyond passwords. In this article, we are going to explore how to implement passwordless authentication. As our client will be a web browser, we don't need to interact directly with CTAP2; it's handled by the browser under the hood. So we will focus on WebAuthn, which is the web API that browsers expose to web applications, and we will call it to create and use passwordless credentials. This guide explores the implementation of WebAuthn using the [Yubico WebAuthn library](https://github.com/Yubico/java-webauthn-server), demonstrating both passkey registration and authentication flows. The implementation showcases how modern web applications can eliminate passwords while enhancing both security and user experience. ## Requirements To follow along with this guide, you will need the following tools and libraries: 1. Latest version of Google Chrome, Mozilla Firefox, Microsoft Edge, or Apple Safari 2. A FIDO2-compliant authenticator (e.g., YubiKey) 3. Yubico's WebAuthn library Please add the following dependency to your build.sbt file: ```scala libraryDependencies += "com.yubico" % "webauthn-server-core" % "2.7.0" libraryDependencies += "com.yubico" % "webauthn-server-attestation" % "2.7.0" ``` ## Key Concepts Before diving into the details of how WebAuthn works and the implementation details, let's define some key concepts and terminology: 1. **Relying Party (RP)**: The server or service that wants to authenticate the user and is responsible for generating challenges and verifying responses. 2. **Client (User Agent)**: The web browser or application that mediates between the Relying Party and the Authenticator using the WebAuthn API. 3. **Authenticator**: A device or software that proves your identity by generating or storing credentials (like cryptographic keys, passwords, or biometric data). - Platform authenticator: Built into the device (e.g., Touch ID, Windows Hello, Android biometrics) - Roaming authenticator: External devices that can be used across multiple platforms (e.g., YubiKey, FIDO2 USB Keys) 4. **Challenge**: A unique, server-generated random value sent to the authenticator to be signed, ensuring **the freshness of each authentication attempt** (preventing replay attacks) and allowing the relying party to **confirm the response truly comes from the rightful account owner's authenticator that has the right private key**. 5. **User Presence**: A flag that the relying party can request to ensure that the user is physically present during the authentication process (e.g., by touching the security key or using biometrics). 6. **User Verification**: A flag that the relying party can request to ensure that the user has been verified by the authenticator (e.g., using a PIN or biometric verification). It is more stringent than user presence, which only requires a simple action like touching the authenticator. 7. **Credential ID**: A unique identifier for a credential (public/private key pair) created by the authenticator during registration. The relying party uses this ID to look up the associated public key during authentication. ## FIDO2: The Umbrella Project FIDO2 is an umbrella project that encompasses two main components: **WebAuthn** and **CTAP2**. Together, they enable passwordless authentication using public-key cryptography and device-based authenticators. WebAuthn is part of the FIDO standard, which is published by the World Wide Web Consortium (W3C). It is a JavaScript API that enables web clients to interact with authenticators. It has two main operations: - **[`navigator.credentials.create()`](https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/create)**: Takes registration options and asks the authenticator to create a new credential (a pair of private and public keys). - **[`navigator.credentials.get()`](https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/get)**: Takes authentication options and asks the authenticator to sign a given challenge with the private key. We will discuss these operations in more detail later. CTAP2 is a client-to-authenticator protocol that defines how a client (like your PC, phone, or browser) communicates with an authenticator. As we are going to write a web application, we don't need to interact directly with CTAP2; it's handled by the browser under the hood. ## Why and How WebAuthn is Secure WebAuthn's security architecture represents a paradigm shift from password-based authentication, addressing fundamental vulnerabilities that have plagued online security for decades. In this guide, we aren't going to cover security in depth, but the key aspects that make WebAuthn a robust and secure authentication mechanism are: 1. At its core, WebAuthn uses asymmetric cryptography, where each user has a unique public/private key pair. The private key remains securely stored on the user's device (the authenticator), while the public key is registered with the service (the relying party). This design ensures that even if a service's database is compromised, attackers cannot derive the private keys, rendering stolen data useless for authentication. 2. During authentication, the server issues a unique, random challenge that the authenticator must sign using its private key. This challenge-response mechanism ensures that only the legitimate user can authenticate, as the private key never leaves the device. Additionally, the challenge is unique for each authentication attempt, preventing replay attacks. 3. WebAuthn is architecturally designed to be phishing-resistant through origin binding. When you want to log in with WebAuthn, the authenticator checks the website's origin against the origin you registered with. If there is a subtle difference that the human eye might not notice, the authenticator will refuse to sign the challenge. This mechanism effectively thwarts phishing attacks, as attackers cannot trick users into authenticating on fraudulent sites. ## Discoverable and Non-Discoverable Credentials Let's define what discoverable and non-discoverable credentials are: 1. **Non-discoverable credentials**: These credentials require the user to provide a username or identifier during the authentication process. When the user attempts to log in, they must first enter their username, which the relying party uses to look up the associated credentials. The relying party asks the browser to use them in the authentication process. In this type of credential, the authenticator may not know which credential to use until the relying party tells it. Please note that sometimes non-resident keys are also called non-discoverable credentials; this is because these types of credentials are not required to be stored on the authenticator devices. Instead, it is the responsibility of the relying party to store them. They only have an internal mechanism to derive the private key from their master secret and the credential information that the relying party provides. 2. **Discoverable Credentials**: These credentials can be used without the user providing a username or identifier. The authenticator itself stores the user information alongside the credential, allowing it to identify and use the correct credential during authentication. This enables a more seamless and user-friendly experience, as users can authenticate without needing to remember the username. During authentication, the authenticator prompts the user to select from a list of available credentials. These types of credentials are also called resident keys because they are stored on the authenticator device itself. There is another term you may hear in this context: passkey. Passkeys are discoverable credentials that can be synced across devices through providers such as Google Password Manager, iCloud Keychain, and Windows Hello. So they are not device-bound and can be used from any device. In this guide, we will cover both types of credentials and demonstrate how to implement them using the Yubico WebAuthn library. ## Registration and Authentication Flows After learning the key concepts, we are ready to explore the registration and authentication flows, but in the simplest form. ## Implementation Overview Based on the registration and authentication flows, the client and server sides each have distinct responsibilities: 1. Client-side (Browser) is responsible for communicating between the Relying Party (your server) and the Authenticator to: - Create new credentials (public/private key pair) - Sign challenges to prove the authenticator possesses the private key 2. Server-side is responsible for: - Generating a challenge - Verifying the registration and authentication responses from the client - Storing and managing user credentials (public keys) - Managing user sessions ## Registration Flow Implementation The **registration flow** involves the following steps: 1. **Client (Browser)** requests registration options from the **Relying Party** (your server). 2. **Relying Party** generates a challenge and sends registration options to the **Client**. 3. **Client** invokes the authenticator to create a new credential (public/private key pair). 4. **Authenticator** creates the credential and returns it to the **Client**. 5. **Client** sends the credential to the **Relying Party**. 6. **Relying Party** verifies the credential and stores the public key. 7. If verification is successful, the **Relying Party** associates the public key with the user's account. 8. **Relying Party** confirms successful registration to the **Client**. 9. **Client** confirms successful registration to the User. To manage the registration process, we define a `WebAuthnService` trait with two methods: `startRegistration` and `finishRegistration`: ```scala trait WebAuthnService { def startRegistration(request: RegistrationStartRequest): IO[String, RegistrationStartResponse] def finishRegistration(request: RegistrationFinishRequest): IO[String, RegistrationFinishResponse] } ``` 1. The `startRegistration` method is responsible for generating public key creation options to be sent to the client. The client uses these options to prompt the authenticator to create a new credential. 2. The `finishRegistration` method is responsible for verifying the response from the client and storing the public key associated with the user to complete the registration process. Each of these two methods corresponds to an API endpoint: 1. `POST /api/webauthn/registration/start`: This endpoint initiates the registration process by generating and returning the public key creation options to the client. 2. `POST /api/webauthn/registration/finish`: This endpoint completes the registration process by verifying the response from the client. Let's start by implementing the start and finish routes for the registration flow. ### Registration Routes #### 1. Start Route The start endpoint is responsible for generating the registration options and sending them to the client: ```scala val registrationStartRoute = Method.POST / "api" / "webauthn" / "registration" / "start" -> handler { (req: Request) => for { request <- req.body.to[RegistrationStartRequest] response <- webauthn.startRegistration(request.username) } yield Response.json(response.toJson) } ``` It takes a `RegistrationStartRequest` object containing the username, and then it calls the `startRegistration` method of the `WebAuthnService` and returns a `PublicKeyCredentialCreationOptions` object containing the registration options. The `RegistrationStartRequest` and `RegistrationStartResponse` types are defined as follows: ```scala case class RegistrationStartRequest(username: String) type RegistrationStartResponse = com.yubico.webauthn.data.PublicKeyCredentialCreationOptions ``` 1. **`RegistrationStartRequest`** contains the username of the user who wants to register a new credential. 2. **`RegistrationStartResponse`** is a type alias of `PublicKeyCredentialCreationOptions` from the Yubico WebAuthn library, which contains the public key credential options that the client needs to create a new credential. What are registration options? They are all the information the client needs to create a new key pair credential. It includes the challenge, relying party information, user information, and other parameters. To deserialize the request body into a `RegistrationStartRequest` object, we need to define a JSON codec for it: ```scala object RegistrationStartRequest { implicit val codec: JsonCodec[RegistrationStartRequest] = DeriveJsonCodec.gen } ``` As the `RegistrationStartResponse` data type is a type alias of `PublicKeyCredentialCreationOptions`, it supports JSON serialization out of the box. We can directly convert it to JSON using the `PublicKeyCredentialCreationOptions#toJson` method. This route is responsible for generating the [`PublicKeyCredentialCreationOptions`](https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredentialCreationOptions) which looks like this: ```json { "rp": { "name": "WebAuthn Demo", "id": "localhost" }, "user": { "name": "john", "displayName": "john", "id": "OTk4NDdkZjItZTRkNy00N2YyLTgxNTEtZTljY2FhODhkZTZl" }, "challenge": "KvgPsSdLEyhLaB9r9m3EQs6UyunnPHopdGmM1G2SlE4", "pubKeyCredParams": [ { "alg": -8, "type": "public-key" }, { "alg": -7, "type": "public-key" }, { "alg": -257, "type": "public-key" } ], "timeout": 60000, "hints": [], "authenticatorSelection": { "requireResidentKey": true, "residentKey": "required", "userVerification": "required" }, "attestation": "none", "extensions": {} } ``` Here is an overview of each field: 1. **`rp`**: Contains information about the relying party (your server), including its name and ID (domain). The `rp.id` must match the domain you want the credential scoped to (no scheme, no port), e.g., `example.com` or `login.example.com`. If you set it to `login.example.com`, the credential will not work on `example.com` or `shop.example.com`. Conversely, if you set it to `example.com`, the credential will work on all subdomains. The `rp.name` is a human-readable name for the relying party, which is shown to the user in the authenticator UI. 2. **`user`**: Contains information about the user, including their username, display name, and a unique identifier (ID). The `user.id` is a server-generated unique identifier for the user, encoded as a base64url string. Please note that the "user id" is also called "user handle" in the WebAuthn specification, so they are the same thing. The `user.name` is the username of the user, and the `user.displayName` is a human-readable name for the user, which is shown to the user in the authenticator UI. 3. **`challenge`**: A server-generated random value (base64url-encoded) that the authenticator must sign to prove possession of the private key. 4. **`pubKeyCredParams`**: An array of objects specifying the acceptable public key algorithms for the credential. Each object contains an `alg` field (COSE algorithm identifier) and a `type` field (always "public-key"). 5. **`timeout`**: The time (in milliseconds) that the client should wait for the user to complete the registration process. 6. **`hints`**: An array of strings that provide hints to the client (browser) about which type of authenticator to use and what the order of our preference is. 7. **`authenticatorSelection`**: An object that specifies criteria for selecting the authenticator. It includes: - `requireResidentKey`: A boolean indicating whether a resident key (discoverable credential) is required. - `residentKey`: A string indicating the requirement level for resident keys ("required", "preferred", or "discouraged"). - `userVerification`: A string indicating the requirement level for user verification ("required", "preferred", or "discouraged"). 8. **`attestation`**: Defines the attestation conveyance preference, which indicates how the relying party wants to receive attestation information from the authenticator. It can be "none", "indirect", or "direct". In this example, we set it to "none" to simplify the process and avoid dealing with attestation verification. 9. **`extensions`**: An object that can contain additional extension data for the registration process. In this example, we leave it empty. #### 2. Finish Route After the client sends the registration start request and receives the registration options, it invokes the authenticator to create a new credential. The authenticator returns the credential to the client, which then sends it to the relying party to finish the registration ceremony. So we have to implement the finish endpoint to handle this request. The finish endpoint is responsible for verifying the response from the client and storing the public key: ```scala val registrationFinishRoute = Method.POST / "api" / "webauthn" / "registration" / "finish" -> handler { (req: Request) => for { body <- req.body.asString request <- ZIO.fromEither(body.fromJson[RegistrationFinishRequest]) result <- webauthn.finishRegistration(request).orElseFail { Response( status = Status.InternalServerError, body = Body.fromString(s"Registration failed!"), ) } } yield Response(body = Body.from(result)) } ``` It takes a `RegistrationFinishRequest` object containing the response from the client, then it calls the `finishRegistration` method of the `WebAuthnService`, and returns a `RegistrationFinishResponse` object indicating whether the registration was successful. The `RegistrationFinishRequest` and `RegistrationFinishResponse` types are defined as follows: ```scala case class RegistrationFinishRequest( username: String, userhandle: String, publicKeyCredential: PublicKeyCredential[AuthenticatorAttestationResponse, ClientRegistrationExtensionOutputs], ) case class RegistrationFinishResponse( success: Boolean, credentialId: String, ) ``` 1. The `RegistrationFinishRequest` contains a username, user handle, and the `PublicKeyCredential` object returned by the authenticator. The `PublicKeyCredential` is a generic type that takes two type parameters: the response type and the extension outputs type. In this case, we use `AuthenticatorAttestationResponse` as the response type and `ClientRegistrationExtensionOutputs` as the extension outputs type. 2. The `RegistrationFinishResponse` contains a success flag and the credential ID of the newly created credential. :::note Please note that we have two types of authenticator responses: 1. `AuthenticatorAttestationResponse`: Used during the **registration** ceremony and contains the attestation object and client data JSON. 2. `AuthenticatorAssertionResponse`: Used during the **authentication** ceremony and contains the authenticator data, client data JSON, signature, and user handle. As we are implementing the registration flow, we use `AuthenticatorAttestationResponse`. ::: Here is an example response from the authenticator after the client prompts the authenticator to create a new credential: ```json { "username": "milad", "userhandle": "cfaf4026-21e7-4bfb-909c-9b514fc52ac4", "publicKeyCredential": { "authenticatorAttachment": "cross-platform", "clientExtensionResults": {}, "id": "xil3bQiqYNMnsTD6JV6LZA", "rawId": "xil3bQiqYNMnsTD6JV6LZA", "response": { "attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViUSZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAOqbjWZNAR0hPOS2tIy1ddQAEMYpd20IqmDTJ7Ew-iVei2SlAQIDJiABIVgggd6fPJYYYdbHuIwo_F3NhNtQS0NkK71IEN_hasDbLxUiWCAfUA-DngJiyOy2X2Ze4qWp6zIZA2wG5ymfS3zBMHd8VA", "authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAOqbjWZNAR0hPOS2tIy1ddQAEMYpd20IqmDTJ7Ew-iVei2SlAQIDJiABIVgggd6fPJYYYdbHuIwo_F3NhNtQS0NkK71IEN_hasDbLxUiWCAfUA-DngJiyOy2X2Ze4qWp6zIZA2wG5ymfS3zBMHd8VA", "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiSzg2RUZVMTdMSzB5LXFMOVh0dDJYLXdyMnVaaUtxaC13bFJtRlpUWGxGdyIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODA4MCIsImNyb3NzT3JpZ2luIjpmYWxzZX0", "publicKey": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEgd6fPJYYYdbHuIwo_F3NhNtQS0NkK71IEN_hasDbLxUfUA-DngJiyOy2X2Ze4qWp6zIZA2wG5ymfS3zBMHd8VA", "publicKeyAlgorithm": -7, "transports": [ "hybrid", "internal" ] }, "type": "public-key" } } ``` It contains the public key credential created by the authenticator, along with the username and user handle. Using this information, the server can verify the credential and store the public key associated with the user. To be able to serialize and deserialize the `RegistrationFinishRequest` and `RegistrationFinishResponse` types to and from JSON, we need to define JSON codecs for them. For the `RegistrationFinishRequest`, we define a JSON decoder as below: ```scala object RegistrationFinishRequest { implicit val decoder: JsonDecoder[RegistrationFinishRequest] = JsonDecoder[Json].mapOrFail { o => for { u <- o.get(JsonCursor.field("username")).flatMap(_.as[String]) uh <- o.get(JsonCursor.field("userhandle")).flatMap(_.as[String]) pkc <- o .get(JsonCursor.field("publicKeyCredential")) .map(_.toString()) .map(PublicKeyCredential.parseRegistrationResponseJson) } yield RegistrationFinishRequest(u, uh, pkc) } } ``` As the `PublicKeyCredential` type has built-in JSON parsing support in the Yubico WebAuthn library, instead of reinventing the wheel, we can use the `PublicKeyCredential.parseRegistrationResponseJson` method to parse the `publicKeyCredential` field from the JSON object. For the `RegistrationFinishResponse`, we can define a JSON codec straightforwardly as below: ```scala object RegistrationFinishResponse { implicit val codec: JsonCodec[RegistrationFinishResponse] = DeriveJsonCodec.gen } ``` It helps us to directly convert the `RegistrationFinishResponse` object to the JSON format when returning it in the response. ### WebAuthn Service The `WebAuthnService` interface is responsible for handling the core logic of the registration and authentication flow. In this section, we will focus on implementing the registration flow, which includes the `startRegistration` and `finishRegistration` methods: ```scala trait WebAuthnService { def startRegistration(request: RegistrationStartRequest): IO[String, RegistrationStartResponse] def finishRegistration(request: RegistrationFinishRequest): IO[String, RegistrationFinishResponse] } ``` 1. The `startRegistration` method is responsible for generating public key creation options to be sent to the client. The client uses these options to prompt the authenticator to create a new credential. 2. The `finishRegistration` method is responsible for verifying the response from the client and storing the public key associated with the user to complete the registration process. Let's start by implementing the `startRegistration` method. #### 1. Start Registration To implement the `startRegistration` method, we should perform the following steps: 1. Check if the user exists in our system. If not, we create a new user with the given username. 2. Generate the registration options (`PublicKeyCredentialCreationOptions`) using the Yubico WebAuthn library. 3. Store the registration options somewhere (e.g., in-memory map or database) so that we can retrieve them later when the client sends the response to finish the registration ceremony. Now we are ready to implement the `WebAuthnService`: ```scala class WebAuthnServiceImpl( userService: UserService, pendingRegistrations: Ref[Map[UserHandle, RegistrationStartResponse]], ) extends WebAuthnService { private val relyingPartyIdentity: RelyingPartyIdentity = ??? private val relyingParty : RelyingParty = ??? private def userIdentity(userId: String, username: String): UserIdentity = ??? override def startRegistration(request: RegistrationStartRequest): UIO[RegistrationStartResponse] = userService .getUser(request.username) .orElse { val user = User(UUID.randomUUID().toString, request.username, Set.empty) userService.addUser(user).as(user) } .orDieWith(_ => new IllegalStateException("Unexpected status in registration flow!")) .flatMap { user => val creationOptions = generateCreationOptions(relyingPartyIdentity, userIdentity(user.userHandle, request.username)); pendingRegistrations.update(_.updated(user.userHandle, creationOptions)).as(creationOptions) } override def finishRegistration( request: RegistrationFinishRequest, ): ZIO[Any, String, RegistrationFinishResponse] = ??? } ``` The `startRegistration` method first checks if the user exists in the system. If not, it creates a new user with the given username. It generates a random user ID (user handle) for the new user. Then it generates the registration options by calling the `generateCreationOptions` method. The `generateCreationOptions` takes two parameters: `RelyingPartyIdentity` and `UserIdentity`. The `RelyingPartyIdentity` contains information about the relying party (your server), and the `UserIdentity` contains information about the user. We used the `generateCreationOptions` helper method to create the `PublicKeyCredentialCreationOptions` object, which can be defined as follows: ```scala def generateCreationOptions( relyingPartyIdentity: RelyingPartyIdentity, userIdentity: UserIdentity, timeout: Duration = 1.minutes, ): PublicKeyCredentialCreationOptions = { PublicKeyCredentialCreationOptions .builder() .rp(relyingPartyIdentity) .user(userIdentity) .challenge(generateChallenge()) .pubKeyCredParams( List( PublicKeyCredentialParameters.EdDSA, PublicKeyCredentialParameters.ES256, PublicKeyCredentialParameters.RS256, ).asJava, ) .authenticatorSelection( AuthenticatorSelectionCriteria .builder() .residentKey(ResidentKeyRequirement.REQUIRED) .userVerification(UserVerificationRequirement.REQUIRED) .build(), ) .attestation(AttestationConveyancePreference.NONE) .timeout(timeout.toMillis) .build() } ``` The `generateChallenge` function generates a unique challenge using a secure random number generator: ```scala object Crypto { val secureRandom: SecureRandom = new SecureRandom() } def generateChallenge(): ByteArray = { val bytes = new Array[Byte](32) Crypto.secureRandom.nextBytes(bytes) new ByteArray(bytes) } ``` The `RelyingPartyIdentity` can be defined as follows: ```scala val relyingPartyIdentity: RelyingPartyIdentity = RelyingPartyIdentity .builder() .id("localhost") .name("WebAuthn Demo") .build() ``` It specifies the relying party ID (domain) and name. Similarly, we have to generate the `UserIdentity` object based on the user information: ```scala def userIdentity(userId: String, username: String): UserIdentity = UserIdentity .builder() .name(username) .displayName(username) .id(new ByteArray(userId.getBytes())) .build() ``` After creating credential creation options, before returning it to the client, we need to store it somewhere so that we can retrieve it later when the client sends the response to finish the registration ceremony. To keep track of the registration attempts, we store it in an in-memory map (`pendingRegistrations`) with the user handle as the key. #### 2. Finish Registration Now, let's see how to implement the `finishRegistration` method in the `WebAuthnService`. In this method, we have to perform the following steps: 1. Find the corresponding pending registration options for the user handle provided in the request. Why? Because we need to verify the response from the client against the original challenge and options that were sent to the client during the registration start step. 2. To verify the received response, we use the `RelyingParty#finishRegistration` method from the Yubico WebAuthn library. This method takes a `FinishRegistrationOptions` object, which contains the original registration options and the response from the client. It performs all the necessary validations and returns a `RegistrationResult` object. 3. If the registration is successful and the user is verified, we store the public key and other relevant information in our user service. This enables us to authenticate the user in future login attempts. 4. Finally, we return a `RegistrationFinishResponse` object, which contains a success flag and the credential ID. Here is our implementation of the `finishRegistration` method: ```scala class WebAuthnServiceImpl( userService: UserService, pendingRegistrations: Ref[Map[UserHandle, RegistrationStartResponse]], ) extends WebAuthnService { private val relyingPartyIdentity: RelyingPartyIdentity = ??? private val relyingParty: RelyingParty = ??? private def userIdentity(userId: String, username: String): UserIdentity = ??? override def finishRegistration( request: RegistrationFinishRequest, ): IO[String, RegistrationFinishResponse] = for { creationOptions <- pendingRegistrations.get .map(_.get(request.userhandle)) .some .orElseFail(s"no registration request found for ${request.username} username") result = relyingParty.finishRegistration( FinishRegistrationOptions .builder() .request(creationOptions) .response(request.publicKeyCredential) .build(), ) _ <- userService .addCredential( userHandle = request.userhandle, credential = UserCredential( userHandle = creationOptions.getUser.getId, credentialId = result.getKeyId.getId, publicKeyCose = result.getPublicKeyCose, signatureCount = result.getSignatureCount, ), ) .orElseFail(s"${request.username} user not found!") _ <- pendingRegistrations.update(_.removed(request.userhandle)) } yield { RegistrationFinishResponse( success = true, credentialId = result.getKeyId.getId.getBase64Url, ) } } ``` The `finishRegistration` method uses the RP (Relying Party) instance. It can be created as follows: ```scala val relyingPartyIdentity: RelyingPartyIdentity = RelyingPartyIdentity .builder() .id("localhost") .name("WebAuthn Demo") .build() val relyingParty: RelyingParty = RelyingParty .builder() .identity(relyingPartyIdentity) .credentialRepository(new InMemoryCredentialRepository(userService)) .origins(Set("http://localhost:8080").asJava) .build() ``` To create a `RelyingParty`, we need to provide the `RelyingPartyIdentity`, a `CredentialRepository`, and a set of allowed origins. The `CredentialRepository` is an interface that the Yubico WebAuthn library uses to look up credentials during the authentication process. We will implement it later. Before that, let's see how we can manage users and their credentials. The important part about both the registration and authentication processes is the persistence of user information and credentials. To manage users and their credentials, we define the `UserService` that supports the following operations: ```scala trait UserService { def getUser(username: String): IO[UserNotFound, User] def getUserByHandle(userHandle: String): IO[UserNotFound, User] def addUser(user: User): IO[UserAlreadyExists, Unit] def addCredential(userHandle: String, credential: UserCredential): IO[UserNotFound, Unit] def getCredentialById(credentialId: String): UIO[Set[UserCredential]] } ``` Where the `User` and `UserCredential` types are defined as follows: ```scala case class User( userHandle: String, username: String, credentials: Set[UserCredential], ) case class UserCredential( userHandle: ByteArray, credentialId: ByteArray, publicKeyCose: ByteArray, signatureCount: Long, ) ``` The `UserCredential` type represents a public key credential extracted from the `RegistrationResult` object returned by `RelyingParty#finishRegistration`. It contains the user handle, credential ID, public key in COSE format, and signature count and we will store it in our user service. The `UserServiceError` type is defined as follows: ```scala sealed trait UserServiceError object UserServiceError { case class UserNotFound(username: String) extends UserServiceError case class UserAlreadyExists(username: String) extends UserServiceError } ``` As the implementation of `UserService` is not the main focus of this guide, we will provide a simple in-memory implementation: ```scala case class UserServiceLive(users: Ref[Map[String, User]]) extends UserService { override def getUser(username: String): IO[UserNotFound, User] = users.get.flatMap { userMap => ZIO.fromOption(userMap.get(username)).orElseFail(UserNotFound(username)) } override def addUser(user: User): IO[UserAlreadyExists, Unit] = users.get.flatMap { userMap => ZIO.when(userMap.contains(user.username)) { ZIO.fail(UserAlreadyExists(user.username)) } *> users.update(_.updated(user.username, user)) } override def addCredential(userHandle: String, credential: UserCredential): IO[UserNotFound, Unit] = users.get.flatMap { userMap => ZIO.fromOption(userMap.values.find(_.userHandle == userHandle)).orElseFail(UserNotFound(userHandle)).flatMap { user => val updatedCredentials = user.credentials + credential val updatedUser = user.copy(credentials = updatedCredentials) users.update(_.updated(user.username, updatedUser)) } } override def getCredentialById(credentialId: String): IO[Nothing, Set[UserCredential]] = users.get.map { userMap => userMap.values .flatMap(_.credentials) .filter(_.credentialId.getBytes.sameElements(credentialId.getBytes)) .toSet } override def getUserByHandle(userHandle: String): IO[UserNotFound, User] = users.get.flatMap { userMap => ZIO.fromOption(userMap.values.find(_.userHandle == userHandle)).orElseFail(UserNotFound(userHandle)) } } ``` These methods allow us to manage users and their credentials, and also help us to implement the `CredentialRepository` interface required by the Yubico WebAuthn library. Let's see how we can implement it: ```scala class InMemoryCredentialRepository(userService: UserService) extends CredentialRepository { override def getCredentialIdsForUsername(username: String): util.Set[PublicKeyCredentialDescriptor] = Unsafe.unsafe { implicit unsafe => Runtime.default.unsafe.run { userService .getUser(username) .map(_.credentials) .orElseSucceed(Set.empty) .map { _.map { cred => PublicKeyCredentialDescriptor .builder() .id(cred.credentialId) .build() } } .map(_.toSet) .map(_.asJava) }.getOrThrow() } override def getUserHandleForUsername(username: String): Optional[ByteArray] = Unsafe.unsafe { implicit unsafe => Runtime.default.unsafe.run { userService .getUser(username) .map { user => new ByteArray(user.userHandle.getBytes()) } .option }.getOrThrow() }.toJava override def getUsernameForUserHandle(userHandle: ByteArray): Optional[String] = Unsafe.unsafe { implicit unsafe => Runtime.default.unsafe.run { userService.getUserByHandle(new String(userHandle.getBytes)).map(_.username).option }.getOrThrow() }.toJava override def lookup(credentialId: ByteArray, userHandle: ByteArray): Optional[RegisteredCredential] = Unsafe.unsafe { implicit unsafe => Runtime.default.unsafe.run { userService .getUserByHandle(new String(userHandle.getBytes)) .flatMap { user => val credOpt = user.credentials.find(_.credentialId == credentialId) credOpt match { case Some(cred) => ZIO.succeed(cred) case None => ZIO.fail(new Exception("Credential not found")) } } .map(toRegisteredCredential) .option }.getOrThrow() }.toJava override def lookupAll(credentialId: ByteArray): util.Set[RegisteredCredential] = Unsafe.unsafe { implicit unsafe => Runtime.default.unsafe.run { userService.getCredentialById(new String(credentialId.getBytes)) }.getOrThrow().map(toRegisteredCredential).asJava } private def toRegisteredCredential(cred: UserCredential): RegisteredCredential = RegisteredCredential .builder() .credentialId(cred.credentialId) .userHandle(cred.userHandle) .publicKeyCose(cred.publicKeyCose) .signatureCount(cred.signatureCount) .build() } ``` In this implementation, we use the `UserService` to look up users and their credentials. As we are touching the boundary between ZIO and Java, we need to use `Unsafe.unsafe` and `Runtime.default.unsafe.run` to run ZIO effects and get the results. ### Client-Side Implementation Let's start by implementing the registration flow by writing the client-side registration form: ```html ``` After the user enters a username and clicks the "Create Passkey" button, the `registerPasskey` function is called. This function retrieves the username from the input field and initiates the registration process by calling the `performRegistration` function: ```javascript async function registerPasskey() { const username = document.getElementById("passkey-username").value; if (!username) { alert("Please enter a username to register") return; } const success = await performRegistration({ username }); if (success) { document.getElementById("passkey-username").value = ""; alert("Passkey registered successfully!"); } } ``` The `performRegistration` function handles the entire registration flow: ```javascript async function performRegistration({ username }) { try { const requestBody = { username }; const startRes = await fetch("/api/webauthn/registration/start", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(requestBody), }); if (!startRes.ok) throw new Error("Failed to start registration"); const serverOptions = await startRes.json(); const credential = await navigator.credentials.create({ publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(serverOptions), }); const credentialJSON = credential.toJSON(); const credentialForServer = { username: username, userhandle: atob(serverOptions.user.id), publicKeyCredential: credentialJSON, }; const finishRes = await fetch("/api/webauthn/registration/finish", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(credentialForServer), }); let resBody = await finishRes.json() if (!finishRes.ok) throw new Error("Failed to finish registration"); alert("Passkey registered successfully! You can now sign in using your passkey."); return true; } catch (err) { alert("Failed to register passkey: " + err.message); return false; } } ``` The `performRegistration` function performs the following steps: 1. Sends a `POST` request to the `/api/webauthn/registration/start` endpoint with the username to initiate the registration process. The server responds with registration options. 2. Parses the returned options using `PublicKeyCredential.parseCreationOptionsFromJSON`, then calls `navigator.credentials.create()` to prompt the authenticator to generate a new credential. 3. Converts the created credential to JSON using the `toJSON()` method. 4. Sends another `POST` request to the `/api/webauthn/registration/finish` endpoint with the username, user handle, and serialized credential to complete the registration. 5. If registration succeeds, it notifies the user with a success message and returns `true`. If any step fails, it alerts the user with an error message and returns `false`. ## Authentication Flow Implementation After successful registration, the user can authenticate using their passkey. The **authentication flow** involves the following steps: 1. **Client** (Browser) requests authentication options from the **Relying Party** (your server). 2. **Relying Party** generates a challenge and sends authentication options to the **Client**. 3. **Client** invokes the **Authenticator** to sign the challenge to prove that the authenticator possesses the private key. 4. **Authenticator** signs the challenge and returns the signature to the **Client**. 5. **Client** sends the signature to the **Relying Party**. 6. **Relying Party** verifies the signature using the stored public key that is associated with the user (this public key was stored during the registration flow). 7. If the signature is valid, the Relying Party confirms successful authentication to the Client. The authentication process is similar to the registration process and involves two main steps: starting the authentication ceremony and finishing it. Let's add them to the `WebAuthnService` interface: ```scala trait WebAuthnService { def startRegistration(request: RegistrationStartRequest): IO[String, RegistrationStartResponse] def finishRegistration(request: RegistrationFinishRequest): IO[String, RegistrationFinishResponse] // two new operations def startAuthentication(request: AuthenticationStartRequest): IO[String, AuthenticationStartResponse] def finishAuthentication(request: AuthenticationFinishRequest): IO[String, AuthenticationFinishResponse] } ``` 1. The `startAuthentication` method is responsible for generating an assertion request to be sent to the client. This request contains a challenge and other options required for authentication. The client uses this request to prompt the authenticator to sign the challenge. 2. The `finishAuthentication` method is responsible for verifying the authenticator response received from the client. It checks the signature provided by the authenticator against the stored public key associated with the user. If the signature is valid, it confirms successful authentication. Each of these two methods corresponds to an API endpoint: 1. `POST /api/webauthn/authentication/start`: This endpoint initiates the authentication process by generating and returning the assertion request to the client. 2. `POST /api/webauthn/authentication/finish`: This endpoint completes the authentication process by verifying the response from the client. ### Authentication Routes Recall that we discussed two types of registrations and authentications: with username and without username. The username-less authentication is more user-friendly and seamless, but it requires the authenticator to support resident keys (discoverable credentials). In this guide, we will implement the authentication flow that supports both types of authentication. #### 1. Start Route The start route is responsible for generating the assertion request to be sent to the client. This request contains a challenge and other options required for authentication. The client uses this request to prompt the authenticator to sign the challenge: ```scala val authenticationStartRoute = Method.POST / "api" / "webauthn" / "authentication" / "start" -> handler { (req: Request) => for { request <- req.body.to[AuthenticationStartRequest] response <- webauthn.startAuthentication(request) } yield Response.json(response.toJson) } ``` The start route takes a JSON object of type `AuthenticationStartRequest` and returns a JSON object of type `AuthenticationStartResponse`: ```scala case class AuthenticationStartRequest(username: Option[String]) type AuthenticationStartResponse = AssertionRequest ``` 1. **`AuthenticationStartRequest`**: We made the `username` field of `AuthenticationStartRequest` optional to support both types of authentication flows (with username and without username). For username-less authentication, we will send `null` or omit the `username` field in the JSON object. 2. **`AuthenticationStartResponse`**: It is a type alias for `AssertionRequest` from the Yubico WebAuthn library, which contains `PublicKeyCredentialRequestOptions` and optional username and user handle. To be able to deserialize the incoming request to its corresponding model, i.e., `AuthenticationStartRequest`, we have to write a codec for it: ```scala object AuthenticationStartRequest { implicit val codec: JsonCodec[AuthenticationStartRequest] = DeriveJsonCodec.gen } ``` The start route responds with an `AuthenticationStartResponse` object, which is a type alias for the `AssertionRequest` data type from the Yubico WebAuthn library. This contains `PublicKeyCredentialRequestOptions` and optional username and user handle. To convert the `AuthenticationStartResponse` to JSON format, we can use the built-in `AssertionRequest#toJson` method. So we do not need to write a custom codec for `AuthenticationStartResponse`. Here is an example response of type `AuthenticationStartResponse` from the server if no username is provided in the request: ```json { "publicKeyCredentialRequestOptions": { "challenge": "nrihUD006_f5FP75E3Ntq5Up136pvAXpYm_nThFVJdY", "timeout": 60000, "hints": [], "rpId": "localhost", "userVerification": "required", "extensions": {} } } ``` It contains the `publicKeyCredentialRequestOptions` field, which is of type `PublicKeyCredentialRequestOptions`. This object contains all the necessary information for the client to prompt the authenticator to sign the challenge: 1. **challenge**: A unique challenge generated by the server to prevent replay attacks. The client must sign this challenge using the private key associated with the credential that is stored in the authenticator. This proves that the client possesses the private key. 2. **timeout**: The time in milliseconds that the client has to complete the authentication process. 3. **rpId**: The relying party identifier, which is typically the domain of the website. 4. **userVerification**: Indicates the level of user verification required. In this case, it is set to "required", meaning the authenticator must verify the user's identity (e.g., via biometric or PIN). If we send a username in the `AuthenticationStartRequest`, the server will generate an assertion request specific to that user. This means that the server will look up the user's credentials and include them in the assertion request. This is useful for scenarios where the user wants to authenticate with a specific username. Besides the `publicKeyCredentialRequestOptions` field, it also includes the `username` or `userhandle` field in the response. Here is an example response when a username (e.g., `john`) is provided in the request: ```json { "publicKeyCredentialRequestOptions": { "challenge": "G4ARnC_LUO8u5EM5bS2BVUc8jB3zhB1vM-6-9pPn1us", "timeout": 60000, "hints": [], "rpId": "localhost", "allowCredentials": [ { "type": "public-key", "id": "y8gtZxKJg_55WumHTKD_dA" }, { "type": "public-key", "id": "wX_IO_8fBxNT5-CyeHY9gg" } ], "userVerification": "required", "extensions": {} }, "username": "john" } ``` The `publicKeyCredentialRequestOptions` field contains an additional field called `allowCredentials`. This field is a list of credentials that the server recognizes for the specified user. The client will use one of these credentials to authenticate. The `username` field contains the username provided in the request. #### 2. Finish Route On the client side, after receiving the assertion request from the server, the client prompts the authenticator to sign the challenge. This is done using the `navigator.credentials.get()` method provided by the WebAuthn API. Modern browsers have built-in support for parsing the JSON object directly, using the [`PublicKeyCredential.parseRequestOptionsFromJSON`](https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredential/parseRequestOptionsFromJSON_static) method. So we can easily parse the received assertion request and convert it to [`PublicKeyCredentialRequestOptions`](https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredentialRequestOptions), which is required by the [`navigator.credentials.get()`](https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/get) method. After prompting the authenticator and getting the assertion response, the client creates a JSON of type `AuthenticationFinishRequest` and sends it to the server to finish the authentication process using the following endpoint: ```scala val authenticationFinishRoute = Method.POST / "api" / "webauthn" / "authentication" / "finish" -> handler { (req: Request) => for { body <- req.body.asString request <- ZIO.fromEither(body.fromJson[AuthenticationFinishRequest]) result <- webauthn .finishAuthentication(request) .orElseFail( Response( status = Status.Unauthorized, body = Body.fromString(s"Authentication failed!"), ), ) } yield Response(body = Body.from(result)) } ``` The finish route takes a JSON object of type `AuthenticationFinishRequest` and returns a JSON object of type `AuthenticationFinishResponse`: ```scala case class AuthenticationFinishRequest( username: Option[String], // Optional for discoverable passkeys publicKeyCredential: PublicKeyCredential[AuthenticatorAssertionResponse, ClientAssertionExtensionOutputs], ) case class AuthenticationFinishResponse( success: Boolean, username: String, ) ``` 1. **`AuthenticationFinishRequest`**: We made the `username` field of `AuthenticationFinishRequest` optional to support both types of authentication flows (with username and without username). For username-less authentication, we will send `null` or omit the `username` field in the JSON object. The `publicKeyCredential` field is of type `PublicKeyCredential` from the Yubico WebAuthn library, which contains the assertion response from the authenticator. 2. **`AuthenticationFinishResponse`**: It contains a success flag and the username of the authenticated user. Here is an example of the JSON object of type `AuthenticationFinishRequest` sent to the server when no username is provided in the request: ```json { "username" : null, "publicKeyCredential": { "authenticatorAttachment": "cross-platform", "clientExtensionResults": {}, "id": "6XXK_FdqGAvpwXpHRTg-jQ", "rawId": "6XXK_FdqGAvpwXpHRTg-jQ", "response": { "authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MdAAAAAA", "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiMXRZTUJOMlFiMVd2a0RBMWFvaE5ReHJhc1BSZ2VlTU1wZTlQWHFYamhGVSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODA4MCIsImNyb3NzT3JpZ2luIjpmYWxzZX0", "signature": "MEUCIDAL9bj4PdhzuKzVwNpxWShcAxskB3NzZUeAmfNAeo35AiEAmfr6d5RO3zflLBZy0MuCnzw7OyhwggM6PEogdU7fO70" }, "type": "public-key" } } ``` The `publicKeyCredential` field contains the assertion response from the authenticator, which includes the authenticator data, client data JSON, and signature. This information is used by the server to verify the authenticity of the authentication attempt. If the authentication is username-based, the `username` field will contain the username provided by the client. To be able to deserialize the `AuthenticationFinishRequest` type from JSON, we need to define a JSON decoder for it. Here is how we can define the decoder: ```scala object AuthenticationFinishRequest { implicit val decoder: JsonDecoder[AuthenticationFinishRequest] = JsonDecoder[Json].mapOrFail { o => for { u <- Right( o.get(JsonCursor.field("username")) .toOption .flatMap(_.as[String].toOption), ) pkc <- o .get(JsonCursor.field("publicKeyCredential")) .map(_.toString()) .map(PublicKeyCredential.parseAssertionResponseJson) } yield AuthenticationFinishRequest(u, pkc) } } ``` We use the `PublicKeyCredential.parseAssertionResponseJson` function from the Yubico WebAuthn library to parse the `publicKeyCredential` field from the JSON object. After receiving the `AuthenticationFinishRequest` from the client, the server needs to verify the assertion response. After verifying the response, the server sends back an `AuthenticationFinishResponse` object to the client, which indicates whether the authentication was successful. The `AuthenticationFinishResponse` type is defined as follows: ```scala case class AuthenticationFinishResponse( success: Boolean, username: String, ) ``` Its JSON codec can be defined straightforwardly as below: ```scala object AuthenticationFinishResponse { implicit val codec: JsonCodec[AuthenticationFinishResponse] = DeriveJsonCodec.gen } ``` Now that we have defined the routes and data types for the authentication flow, let's implement the logic in the `WebAuthnService`. ### WebAuthn Service The `WebAuthnService` interface is responsible for handling the core logic of the WebAuthn authentication flow. In the registration flow, we implemented the `startRegistration` and `finishRegistration` methods. Now, for the authentication flow, we need to implement the `startAuthentication` and `finishAuthentication` methods: ```scala trait WebAuthnService { def startRegistration(request: RegistrationStartRequest): IO[String, RegistrationStartResponse] def finishRegistration(request: RegistrationFinishRequest): IO[String, RegistrationFinishResponse] // new methods def startAuthentication(request: AuthenticationStartRequest): IO[String, AuthenticationStartResponse] def finishAuthentication(request: AuthenticationFinishRequest): IO[String, AuthenticationFinishResponse] } ``` #### 1. Start Authentication The `startAuthentication` method is responsible for generating the assertion request to be sent to the client. This request contains a challenge and other options required for authentication. The client uses this request to prompt the authenticator to sign the challenge. To implement this method, we need to perform the following steps: 1. Generate an assertion request using the `RelyingParty#startAssertion` method from the Yubico WebAuthn library. This method takes a `StartAssertionOptions` object, which contains options for the assertion request, such as the username (if provided), user verification requirement, and timeout, and returns an `AssertionRequest` object. The `AssertionRequest` is another name for `AuthenticationStartResponse`. So we can directly return it as the response of this method. 2. Before returning the assertion request, we have to store the generated assertion request in the `pendingAuthentications` map using the challenge as the key. This allows us to retrieve the original assertion request later when verifying the response from the client. Let's implement the `startAuthentication` method: ```scala type Challenge = String class WebAuthnServiceImpl( userService: UserService, pendingRegistrations: Ref[Map[UserHandle, RegistrationStartResponse]], pendingAuthentications: Ref[Map[Challenge, AuthenticationStartResponse]], ) extends WebAuthnService { private val relyingPartyIdentity: RelyingPartyIdentity = ??? private val relyingParty: RelyingParty = ??? private def userIdentity(userId: String, username: String): UserIdentity = ??? override def startRegistration(request: RegistrationStartRequest): ZIO[Any, Nothing, RegistrationStartResponse] = ??? override def finishRegistration( request: RegistrationFinishRequest, ): IO[String, RegistrationFinishResponse] = ??? override def startAuthentication( request: AuthenticationStartRequest, ): ZIO[Any, Nothing, AuthenticationStartResponse] = { val assertion = generateAssertionRequest(relyingParty, request.username) val challenge = assertion.getPublicKeyCredentialRequestOptions.getChallenge.getBase64Url; pendingAuthentications.update(_.updated(challenge, assertion)).as(assertion) } override def finishAuthentication( request: AuthenticationFinishRequest, ): IO[String, AuthenticationFinishResponse] = ??? } ``` To generate an assertion request, we need to consider whether a username is provided in the request. If a username is provided, we generate an assertion request specific to that user. If no username is provided, we generate a username-less assertion request suitable for discoverable passkeys: ```scala def generateAssertionRequest( relyingParty: RelyingParty, username: Option[String], timeout: Duration = 1.minutes, ): AssertionRequest = { // Create assertion request username match { case Some(user) if user.nonEmpty => // Username-based authentication relyingParty.startAssertion( StartAssertionOptions .builder() .username(user) .userVerification(UserVerificationRequirement.REQUIRED) .timeout(timeout.toMillis) .build(), ) case _ => // Username-less authentication for discoverable passkeys relyingParty.startAssertion( StartAssertionOptions .builder() .userVerification(UserVerificationRequirement.REQUIRED) .timeout(timeout.toMillis) .build(), ) } } ``` The `RelyingParty#startAssertion` method generates an assertion object by performing the following steps: 1. Creates a unique challenge. 2. If a username is provided, retrieves the user’s credentials and adds their credential IDs to the `allowCredentials` field of the `PublicKeyCredentialRequestOptions`. 3. Sets the user verification requirement to `REQUIRED`, ensuring that the authenticator verifies the user’s identity. 4. Defines a timeout value for the authentication process. Finally, the generated assertion request is stored in the `pendingAuthentications` map using the challenge as the key. This allows us to retrieve the original assertion request later when verifying the response from the client. #### 2. Finish Authentication After the client receives the assertion request from the server, it prompts the authenticator to sign the challenge. The client then sends the assertion response back to the server to finish the authentication process. The `finishAuthentication` method is responsible for verifying the response from the client. To implement the `finishAuthentication` method, we need to perform the following steps: 1. Retrieve the challenge from the `publicKeyCredential` provided in the request. 2. Look up the original assertion request from the `pendingAuthentications` map using the challenge. 3. Call the `RelyingParty#finishAssertion` method to verify the response from the client. This method takes the original assertion request and the public key credential response from the client, performs all the necessary validations, and returns an `AssertionResult` that contains the result of the authentication attempt. 4. Before returning the result of authentication, we have to remove the corresponding pending assertion request from the `pendingAuthentications` map using the challenge as the key. 5. If the authentication is successful, we return an `AuthenticationFinishResponse` object, which contains a success flag and the username associated with the authenticated credential. Now, let's implement the `finishAuthentication` method in the `WebAuthnService`: ```scala class WebAuthnServiceImpl( userService: UserService, pendingRegistrations: Ref[Map[UserHandle, RegistrationStartResponse]], pendingAuthentications: Ref[Map[Challenge, AuthenticationStartResponse]], ) extends WebAuthnService { private val relyingPartyIdentity: RelyingPartyIdentity = ??? private val relyingParty: RelyingParty = ??? private def userIdentity(userId: String, username: String): UserIdentity = ??? override def startRegistration( request: RegistrationStartRequest ): ZIO[Any, Nothing, RegistrationStartResponse] = ??? override def finishRegistration( request: RegistrationFinishRequest, ): IO[String, RegistrationFinishResponse] = ??? override def startAuthentication( request: AuthenticationStartRequest, ): ZIO[Any, Nothing, AuthenticationStartResponse] = ??? override def finishAuthentication( request: AuthenticationFinishRequest, ): IO[String, AuthenticationFinishResponse] = for { challenge <- ZIO .succeed(request.publicKeyCredential.getResponse.getClientData.getChallenge.getBase64Url) assertionRequest <- pendingAuthentications.get .map(_.get(challenge)) .some .orElseFail(s"The ${challenge} not found in pending authentication requests!") assertion = relyingParty.finishAssertion( FinishAssertionOptions .builder() .request(assertionRequest) .response(request.publicKeyCredential) .build(), ) _ <- pendingAuthentications.update(_.removed(challenge)) } yield AuthenticationFinishResponse( success = assertion.isSuccess, username = assertion.getUsername, ) } ``` Now that we have implemented the `finishAuthentication` method, the authentication flow is complete. The server can now handle both username-less and username-based authentication requests. We are ready to implement the client-side code to interact with these endpoints. ### Client-Side Implementation The client-side authentication form for username-less authentication looks like this: ```html ``` For username-based authentication, we can have a simple form like this: ```html ``` Both `authenticatePasskey()` and `authenticateWithUsername()` functions call a common function `performAuthentication` to handle the authentication process. Here is how we can implement these functions: ```javascript async function authenticatePasskey() { await performAuthentication({ username: null, isPasskey: true }); } async function authenticateWithUsername() { const username = document.getElementById("username-login").value; if (!username) { alert("Please enter your username to sign in") return; } await performAuthentication({ username, isPasskey: false }); } ``` The `performAuthentication` function handles the entire authentication flow: ```javascript async function performAuthentication({ username, isPasskey }) { try { const requestBody = { username: username }; const startResponse = await fetch("/api/webauthn/authentication/start", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(requestBody), }); if (!startResponse.ok) { throw new Error("Failed to start authentication"); } const serverOptions = await startResponse.json(); const publicKeyOptions = serverOptions.publicKeyCredentialRequestOptions; const assertion = await navigator.credentials.get({ publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(publicKeyOptions), }); const assertionJSON = assertion.toJSON(); const assertionForServer = { username: username, publicKeyCredential: assertionJSON, }; const finishResponse = await fetch("/api/webauthn/authentication/finish", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(assertionForServer), }); const result = await finishResponse.json(); if (!finishResponse.ok || !result.success) { throw new Error(result.message || "Failed to finish authentication"); } // Extract username from result for passkey flow const authenticatedUser = result.username; alert("Authentication successful for user: " + authenticatedUser); return true; } catch (error) { alert("Authentication error: " + error); return false; } } ``` 1. It sends a `POST` request to the `/api/webauthn/authentication/start` endpoint with the username (or `null` for username-less authentication) to start the authentication process. The server responds with the assertion request. 2. It parses the assertion request and calls the `navigator.credentials.get()` method to prompt the authenticator to sign the challenge. 3. It serializes the assertion response to JSON format using the `toJSON()` method. 4. It sends a `POST` request to the `/api/webauthn/authentication/finish` endpoint with the username and the serialized assertion to finish the authentication process. 5. If the authentication is successful, it alerts the user with the authenticated username. ## Source Code The complete source code for this WebAuthn passwordless authentication example is available in the ZIO HTTP repository. To clone the example: ```bash git clone --depth 1 --filter=blob:none --sparse https://github.com/zio/zio-http.git cd zio-http git sparse-checkout set zio-http-example-webauthn ``` ### Running the Server To run the WebAuthn authentication server: ```bash cd zio-http/zio-http-example-webauthn sbt "runMain example.auth.webauthn.WebAuthnServer" ``` The server starts on `http://localhost:8080` and provides endpoints for both registration and authentication ceremonies. ### Using the Web Client The `WebAuthnServer` automatically serves the HTML client located in the resource folder. After starting the server, open [http://localhost:8080](http://localhost:8080) in your browser. The interface provides two main authentication flows: #### Discoverable Credentials (Passkeys) 1. Click "Create Passkey" and enter a username 2. Follow your browser/device prompts to create a passkey 3. Use "Sign In with Passkey" for username-less authentication 4. Your authenticator will prompt you to select and verify your identity #### Username-based Authentication 1. Click "Create Passkey" and enter a username 2. Create your credential following the prompts 3. Enter your username and click "Sign In" 4. Authenticate using the credential associated with that username The HTML client file (`webauthn-client.html`) can be found in the example project's resource folder. ## Demo We have deployed a live demo of the WebAuthn authentication server at: [https://webauthn-demo.ziohttp.com/](https://webauthn-demo.ziohttp.com/) At the bottom of the page, there is a debug section where you can view detailed requests and responses for each step in the registration and authentication processes. ## Conclusion In this guide, we explored the full WebAuthn flow: generating challenges, creating credentials, verifying attestations and assertions, and integrating a browser client that supports both username-based and discoverable (passkey) sign-ins. The result is a phishing-resistant authentication system built on public-key cryptography, offering a smoother user experience than one-time codes or SMS. Using **WebAuthn with ZIO HTTP**, we implemented a complete passwordless authentication solution that supports both registration and authentication ceremonies, handling discoverable credentials (passkeys) as well as traditional username-based flows. WebAuthn mitigates the fundamental weaknesses of password-based authentication by leveraging asymmetric cryptography. Private keys never leave the authenticator device, eliminating credential databases as high-value targets. Origin binding prevents phishing, while the challenge–response mechanism ensures each authentication attempt is unique, non-replayable, and tied to the legitimate credential owner. Finally, when transitioning from development to production, ensure that your WebAuthn setup is correctly configured. WebAuthn requires **HTTPS** for all communications (with the exception of `localhost` during development) and a properly configured relying party identifier (`rp.id`) that matches your production domain. --- ## Authentication Example ## Authentication Server Example ```scala title="zio-http-example/src/main/scala/example/AuthenticationServer.scala" //> using dep "dev.zio::zio-http:3.4.0" //> using dep "com.github.jwt-scala::jwt-core:10.0.4" package example /** * This is an example to demonstrate bearer Authentication middleware. The * Server has 2 routes. The first one is for login, Upon a successful login, it * will return a jwt token for accessing protected routes. The second route is a * protected route that is accessible only if the request has a valid jwt token. * AuthenticationClient example can be used to makes requests to this server. */ object AuthenticationServer extends ZIOAppDefault { implicit val clock: Clock = Clock.systemUTC // Secret Authentication key val SECRET_KEY = "secretKey" def jwtEncode(username: String, key: String): String = Jwt.encode(JwtClaim(subject = Some(username)).issuedNow.expiresIn(300), key, JwtAlgorithm.HS512) def jwtDecode(token: String, key: String): Try[JwtClaim] = Jwt.decode(token, key, Seq(JwtAlgorithm.HS512)) val bearerAuthWithContext: HandlerAspect[Any, String] = HandlerAspect.interceptIncomingHandler(Handler.fromFunctionZIO[Request] { request => request.header(Header.Authorization) match { case Some(Header.Authorization.Bearer(token)) => ZIO .fromTry(jwtDecode(token.value.asString, SECRET_KEY)) .orElseFail(Response.badRequest("Invalid or expired token!")) .flatMap(claim => ZIO.fromOption(claim.subject).orElseFail(Response.badRequest("Missing subject claim!"))) .map(u => (request, u)) case _ => ZIO.fail(Response.unauthorized.addHeaders(Headers(Header.WWWAuthenticate.Bearer(realm = "Access")))) } }) def routes: Routes[Any, Response] = Routes( // A route that is accessible only via a jwt token Method.GET / "profile" / "me" -> handler { (_: Request) => ZIO.serviceWith[String](name => Response.text(s"Welcome $name!")) } @@ bearerAuthWithContext, // A login route that is successful only if the password is the reverse of the username Method.GET / "login" -> handler { (request: Request) => val form = request.body.asMultipartForm.orElseFail(Response.badRequest) for { username <- form .map(_.get("username")) .flatMap(ff => ZIO.fromOption(ff).orElseFail(Response.badRequest("Missing username field!"))) .flatMap(ff => ZIO.fromOption(ff.stringValue).orElseFail(Response.badRequest("Missing username value!"))) password <- form .map(_.get("password")) .flatMap(ff => ZIO.fromOption(ff).orElseFail(Response.badRequest("Missing password field!"))) .flatMap(ff => ZIO.fromOption(ff.stringValue).orElseFail(Response.badRequest("Missing password value!"))) } yield if (password.reverse.hashCode == username.hashCode) Response.text(jwtEncode(username, SECRET_KEY)) else Response.unauthorized("Invalid username or password.") }, ) @@ Middleware.debug override val run = Server.serve(routes).provide(Server.default) } ``` ## Authentication Client Example ```scala title="zio-http-example/src/main/scala/example/AuthenticationClient.scala" //> using dep "dev.zio::zio-http:3.4.0" package example object AuthenticationClient extends ZIOAppDefault { /** * This example is trying to access a protected route in AuthenticationServer * by first making a login request to obtain a jwt token and use it to access * a protected route. Run AuthenticationServer before running this example. */ val url = "http://localhost:8080" val loginUrl = URL.decode(s"${url}/login").toOption.get val greetUrl = URL.decode(s"${url}/profile/me").toOption.get val program = for { client <- ZIO.service[Client] // Making a login request to obtain the jwt token. In this example the password should be the reverse string of username. token <- client .batched( Request .get(loginUrl) .withBody( Body.fromMultipartForm( Form( FormField.simpleField("username", "John"), FormField.simpleField("password", "nhoJ"), ), Boundary("boundary123"), ), ), ) .flatMap(_.body.asString) // Once the jwt token is procured, adding it as a Bearer token in Authorization header while accessing a protected route. response <- client.batched(Request.get(greetUrl).addHeader(Header.Authorization.Bearer(token))) body <- response.body.asString _ <- Console.printLine(body) } yield () override val run = program.provide(Client.default) } ``` ## Middleware Basic Authentication Example ```scala title="zio-http-example/src/main/scala/example/BasicAuth.scala" //> using dep "dev.zio::zio-http:3.4.0" package example object BasicAuth extends ZIOAppDefault { // Http app that requires basic auth val user: Routes[Any, Response] = Routes( Method.GET / "user" / string("name") / "greet" -> handler { (name: String, _: Request) => Response.text(s"Welcome to the ZIO party! ${name}") }, ) // Add basic auth middleware val routes: Routes[Any, Response] = user @@ basicAuth("admin", "admin") val run = Server.serve(routes).provide(Server.default) } ``` --- ## Command-line Interface (CLI) ```scala title="zio-http-example/src/main/scala/example/endpoint/CliExamples.scala" //> using dep "dev.zio::zio-http:3.4.0" //> using dep "dev.zio::zio-schema:1.7.2" package example.endpoint final case class User( @description("The unique identifier of the User") id: Int, @description("The user's name") name: String, @description("The user's email") email: Option[String], ) object User { implicit val schema: Schema[User] = DeriveSchema.gen[User] } final case class Post( @description("The unique identifier of the User") userId: Int, @description("The unique identifier of the Post") postId: Int, @description("The post's contents") contents: String, ) object Post { implicit val schema: Schema[Post] = DeriveSchema.gen[Post] } trait TestCliEndpoints { val getUser = Endpoint(Method.GET / "users" / int("userId") ?? Doc.p("The unique identifier of the user")) .header(HeaderCodec.location ?? Doc.p("The user's location")) .out[User] ?? Doc.p("Get a user by ID") val getUserPosts = Endpoint( Method.GET / "users" / int("userId") ?? Doc.p("The unique identifier of the user") / "posts" / int("postId") ?? Doc.p("The unique identifier of the post"), ) .query( HttpCodec.query[String]("user-name") ?? Doc.p( "The user's name", ), ) .out[List[Post]] ?? Doc.p("Get a user's posts by userId and postId") val createUser = Endpoint(Method.POST / "users") .in[User] .out[String] ?? Doc.p("Create a new user") } object TestCliApp extends zio.cli.ZIOCliDefault with TestCliEndpoints { val cliApp = HttpCliApp .fromEndpoints( name = "users-mgmt", version = "0.0.1", summary = HelpDoc.Span.text("Users management CLI"), footer = HelpDoc.p("Copyright 2023"), host = "localhost", port = 8080, endpoints = Chunk(getUser, getUserPosts, createUser), cliStyle = true, ) .cliApp } object TestCliServer extends zio.ZIOAppDefault with TestCliEndpoints { val getUserRoute = getUser.implementHandler { Handler.fromFunctionZIO { case (id, _) => ZIO.succeed(User(id, "Juanito", Some("juanito@test.com"))).debug("Hello") } } val getUserPostsRoute = getUserPosts.implementHandler { Handler.fromFunction { case (userId, postId, name) => List(Post(userId, postId, name)) } } val createUserRoute = createUser.implementHandler { Handler.fromFunction { user => user.name } } val routes = Routes(getUserRoute, getUserPostsRoute, createUserRoute) @@ Middleware.debug val run = Server.serve(routes).provide(Server.default) } object TestCliClient extends zio.ZIOAppDefault with TestCliEndpoints { val run = clientExample .provide( EndpointExecutor.make(serviceName = "test"), Client.default, ) def clientExample: URIO[EndpointExecutor[Any, Unit, Scope], Unit] = for { executor <- ZIO.service[EndpointExecutor[Any, Unit, Scope]] _ <- ZIO.scoped(executor(getUser(42, Location.parse("some-location").toOption.get))).debug("result1") _ <- ZIO.scoped(executor(getUserPosts(42, 200, "adam")).debug("result2")) _ <- ZIO.scoped(executor(createUser(User(2, "john", Some("john@test.com"))))).debug("result3") } yield () } ``` --- ## Concrete Entity Example ```scala title="zio-http-example/src/main/scala/example/ConcreteEntity.scala" //> using dep "dev.zio::zio-http:3.4.0" package example /** * Example to build app on concrete entity */ object ConcreteEntity extends ZIOAppDefault { // Request case class CreateUser(name: String) // Response case class UserCreated(id: Long) val user: Handler[Any, Nothing, CreateUser, UserCreated] = Handler.fromFunction[CreateUser] { case CreateUser(_) => UserCreated(2) } val routes: Routes[Any, Response] = user .contramap[Request](req => CreateUser(req.path.encode)) // Http[Any, Nothing, Request, UserCreated] .map(userCreated => Response.text(userCreated.id.toString)) // Http[Any, Nothing, Request, Response] .toRoutes // Run it like any simple app val run = Server.serve(routes).provide(Server.default) } ``` --- ## Endpoint Scala 3 Syntax ```scala type NotFound[EntityId] = EntityId type EntityId = UUID val union: ContentCodec[String | UUID | Boolean] = HttpCodec.content[String] || HttpCodec.content[UUID] || HttpCodec.content[Boolean] val unionEndpoint = Endpoint(Method.GET / "api" / "complex-union") .outCodec(union) val unionWithErrorEndpoint : Endpoint[Unit, Unit, NotFound[EntityId] | String, UUID | Unit, AuthType.None] = Endpoint(Method.GET / "api" / "union-with-error") .out[UUID] .orOut[Unit](Status.NoContent) .outError[NotFound[EntityId]](Status.NotFound) .orOutError[String](Status.BadRequest) val impl = unionWithErrorEndpoint.implementEither { _ => val result: Either[NotFound[EntityId] | String, UUID | Unit] = Left("error") result } ``` --- ## Endpoint Examples ```scala title="zio-http-example/src/main/scala/example/EndpointExamples.scala" //> using dep "dev.zio::zio-http:3.4.0" package example object EndpointExamples extends ZIOAppDefault { // MiddlewareSpec can be added at the service level as well val getUser = Endpoint(Method.GET / "users" / int("userId")).out[Int] val getUserRoute = getUser.implement { id => ZIO.succeed(id) } val getUserPosts = Endpoint(Method.GET / "users" / int("userId") / "posts" / int("postId")) .query(HttpCodec.query[String]("name")) .out[List[String]] val getUserPostsRoute = getUserPosts.implement { case (id1: Int, id2: Int, query: String) => ZIO.succeed(List(s"API2 RESULT parsed: users/$id1/posts/$id2?name=$query")) } val openAPI = OpenAPIGen.fromEndpoints(title = "Endpoint Example", version = "1.0", getUser, getUserPosts) val routes = Routes(getUserRoute, getUserPostsRoute) ++ SwaggerUI.routes("docs" / "openapi", openAPI) val app = routes // (auth.implement(_ => ZIO.unit)(_ => ZIO.unit)) val request = Request.get(url = URL.decode("/users/1").toOption.get) val run = Server.serve(app).provide(Server.default) object ClientExample { def example(client: Client) = { val executor: EndpointExecutor[Any, Unit, Scope] = EndpointExecutor(client, url"http://localhost:8080") val x1: Invocation[Int, Int, ZNothing, Int, None] = getUser(42) val x2 = getUserPosts(42, 200, "adam") val result1: ZIO[Scope, Nothing, Int] = executor(x1) val result2: ZIO[Scope, Nothing, List[String]] = executor(x2) result1.zip(result2).debug } } } ``` --- ## Graceful Shutdown Example ```scala title="zio-http-example/src/main/scala/example/GracefulShutdown.scala" /* * Copyright 2021 - 2023 Sporta Technologies PVT LTD & the ZIO HTTP contributors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ //> using dep "dev.zio::zio-http:3.4.0" package example /** * Demonstrates graceful shutdown: the server waits for in-flight requests to * complete before stopping. * * Run this app, then in another terminal execute: * * curl http://localhost:8080/slow * * While the request is in progress, press Ctrl+C in the app's terminal — the * response will still be delivered before the server shuts down. */ object GracefulShutdown extends ZIOAppDefault { val routes = Routes( Method.GET / "hello" -> handler(Response.text("Hello, World!")), Method.GET / "slow" -> handler { ZIO.sleep(5.seconds).as(Response.text("Done after 5 seconds!")) }, ) override def run = Server .serve(routes) .provide( Server.live, ZLayer.succeed( Server.Config.default .port(8080) .gracefulShutdownTimeout(10.seconds), ), ) } ``` --- ## Hello World Example ## Simple Example ```scala title="zio-http-example/src/main/scala/example/HelloWorld.scala" //> using dep "dev.zio::zio-http:3.4.0" package example object HelloWorld extends ZIOAppDefault { // Responds with plain text val homeRoute = Method.GET / Root -> handler(Response.text("Hello World!")) // Responds with JSON val jsonRoute = Method.GET / "json" -> handler(Response.json("""{"greetings": "Hello World!"}""")) // Create HTTP route val app = Routes(homeRoute, jsonRoute) // Run it like any simple app override val run = Server.serve(app).provide(Server.default) } ``` ## Advanced Example ```scala title="zio-http-example/src/main/scala/example/HelloWorldAdvanced.scala" //> using dep "dev.zio::zio-http:3.4.0" package example object HelloWorldAdvanced extends ZIOAppDefault { // Set a port val PORT = 58080 val fooBar = Routes( Method.GET / "foo" -> Handler.from(Response.text("bar")), Method.GET / "bar" -> Handler.from(Response.text("foo")), ) val app = Routes( Method.GET / "random" -> handler(Random.nextString(10).map(Response.text(_))), Method.GET / "utc" -> handler(Clock.currentDateTime.map(s => Response.text(s.toString))), ) val run = ZIOAppArgs.getArgs.flatMap { args => // Configure thread count using CLI val nThreads: Int = args.headOption.flatMap(x => Try(x.toInt).toOption).getOrElse(0) val config = Server.Config.default .port(PORT) val nettyConfig = NettyConfig.default .leakDetection(LeakDetectionLevel.PARANOID) .maxThreads(nThreads) val configLayer = ZLayer.succeed(config) val nettyConfigLayer = ZLayer.succeed(nettyConfig) (fooBar ++ app) .serve[Any] .provide(configLayer, nettyConfigLayer, Server.customized) } } ``` ## Advanced with CORS Example ```scala title="zio-http-example/src/main/scala/example/HelloWorldWithCORS.scala" //> using dep "dev.zio::zio-http:3.4.0" package example object HelloWorldWithCORS extends ZIOAppDefault { val config: CorsConfig = CorsConfig( allowedOrigin = { case origin if origin == Origin.parse("http://localhost:3000").toOption.get => Some(AccessControlAllowOrigin.Specific(origin)) case _ => None }, ) val backend: Routes[Any, Response] = Routes( Method.GET / "json" -> handler(Response.json("""{"message": "Hello World!"}""")), ) @@ cors(config) val frontend: Routes[Any, Response] = Routes( Method.GET / PathCodec.empty -> handler( Response.html( html( p("Message: ", output()), script(""" |// This runs on http://localhost:3000 |fetch("http://localhost:8080/json") | .then((res) => res.json()) | .then((res) => document.querySelector("output").textContent = res.message); |""".stripMargin), ), ), ), ) val frontEndServer = Server.serve(frontend).provide(Server.defaultWithPort(3000)) val backendServer = Server.serve(backend).provide(Server.defaultWithPort(8080)) val run = frontEndServer.zipPar(backendServer) } ``` ## Advanced with Middlewares Example ```scala title="zio-http-example/src/main/scala/example/HelloWorldWithMiddlewares.scala" //> using dep "dev.zio::zio-http:3.4.0" package example object HelloWorldWithMiddlewares extends ZIOAppDefault { val routes: Routes[Any, Response] = Routes( // this will return result instantly Method.GET / "text" -> handler(ZIO.succeed(Response.text("Hello World!"))), // this will return result after 5 seconds, so with 3 seconds timeout it will fail Method.GET / "long-running" -> handler(ZIO.succeed(Response.text("Hello World!")).delay(5 seconds)), ) val serverTime = Middleware.patchZIO(_ => for { currentMilliseconds <- Clock.currentTime(TimeUnit.MILLISECONDS) header = Response.Patch.addHeader("X-Time", currentMilliseconds.toString) } yield header, ) val middlewares = // print debug info about request and response Middleware.debug ++ // close connection if request takes more than 3 seconds Middleware.timeout(3 seconds) ++ // add static header Middleware.addHeader("X-Environment", "Dev") ++ // add dynamic header serverTime // Run it like any simple app val run = Server.serve(routes @@ middlewares).provide(Server.default) } ``` --- ## HTML Templating Example ```scala title="zio-http-example/src/main/scala/example/HtmlTemplating.scala" //> using dep "dev.zio::zio-http:3.4.0" package example object HtmlTemplating extends ZIOAppDefault { // Importing everything from `zio.html` def routes: Routes[Any, Response] = { // Html response takes in a `Html` instance. Handler.html { // Support for default Html tags html( // Support for child nodes head( title("ZIO Http"), ), body( div( // Support for css class names css := "container text-align-left", h1("Hello World"), ul( // Support for inline css styles := "list-style: none", li( // Support for attributes a(href := "/hello/world", "Hello World"), ), li( a(href := "/hello/world/again", "Hello World Again"), ), // Support for Seq of Html elements (2 to 10) map { i => li( a(href := s"/hello/world/i", s"Hello World $i"), ) }, ), ), ), ) } }.toRoutes def run = Server.serve(routes).provide(Server.default) } ``` --- ## HTTP Client-Server Example ## Client and Server Example ```scala title="zio-http-example/src/main/scala/example/ClientServer.scala" //> using dep "dev.zio::zio-http:3.4.0" package example object ClientServer extends ZIOAppDefault { private val url = URL.decode("http://localhost:8080/hello").toOption.get private val app = Routes( Method.GET / "hello" -> handler(Response.text("hello")), Method.GET / "" -> handler(ZClient.batched(Request.get(url))), ).sandbox override val run: ZIO[Environment with ZIOAppArgs with Scope, Any, Any] = Server.serve(app).provide(Server.default, Client.default) } ``` ## Simple Client Example ```scala title="zio-http-example/src/main/scala/example/SimpleClient.scala" //> using dep "dev.zio::zio-http:3.4.0" package example object SimpleClient extends ZIOAppDefault { val url = URL.decode("https://jsonplaceholder.typicode.com/todos").toOption.get val program = for { client <- ZIO.service[Client] res <- client.url(url).batched(Request.get("/")) data <- res.body.asString _ <- Console.printLine(data) } yield () override val run = program.provide(Client.default) } ``` --- ## HTTPS Client and Server Example ## Client Example ```scala title="zio-http-example/src/main/scala/example/HttpsClient.scala" //> using dep "dev.zio::zio-http:3.4.0" package example object HttpsClient extends ZIOAppDefault { val url = URL.decode("https://jsonplaceholder.typicode.com/todos/1").toOption.get val headers = Headers(Header.Host("jsonplaceholder.typicode.com")) val sslConfig = ClientSSLConfig.FromTrustStoreResource( trustStorePath = "truststore.jks", trustStorePassword = "changeit", ) val clientConfig = ZClient.Config.default.ssl(sslConfig) val program = for { data <- ZClient.batched(Request.get(url).addHeaders(headers)) _ <- Console.printLine(data) } yield () val run = program.provide( ZLayer.succeed(clientConfig), Client.customized, NettyClientDriver.live, DnsResolver.default, ZLayer.succeed(NettyConfig.default), ) } ``` ## Server Example ```scala title="zio-http-example/src/main/scala/example/HttpsHelloWorld.scala" //> using dep "dev.zio::zio-http:3.4.0" package example object HttpsHelloWorld extends ZIOAppDefault { // Create HTTP route val routes: Routes[Any, Response] = Routes( Method.GET / "text" -> handler(Response.text("Hello World!")), Method.GET / "json" -> handler(Response.json("""{"greetings": "Hello World!"}""")), ) /** * In this example, a private key and certificate are loaded from resources. * For testing this example with curl, make sure the private key "server.key", * and the certificate "server.crt" are inside the resources directory, which * is by default "src/main/resources". * * You can use the following command to create a self-signed TLS certificate. * This command will create two files: "server.key" and "server.crt". * * openssl req -x509 -newkey rsa:4096 -sha256 -days 365 -nodes \ -keyout * server.key -out server.crt \ -subj "/CN=example.com/OU=?/O=?/L=?/ST=?/C=??" * \ -addext "subjectAltName=DNS:example.com,DNS:www.example.com,IP:10.0.0.1" * * Alternatively you can create the keystore and certificate using the * following link * https://medium.com/@maanadev/netty-with-https-tls-9bf699e07f01 */ val sslConfig = SSLConfig.fromResource( behaviour = SSLConfig.HttpBehaviour.Accept, certPath = "server.crt", keyPath = "server.key", ) private val config = Server.Config.default .port(8090) .ssl(sslConfig) private val configLayer = ZLayer.succeed(config) override val run = Server.serve(routes).provide(configLayer, Server.live) } ``` --- ## Middleware CORS Handling Example ```scala title="zio-http-example/src/main/scala/example/HelloWorldWithCORS.scala" //> using dep "dev.zio::zio-http:3.4.0" package example object HelloWorldWithCORS extends ZIOAppDefault { val config: CorsConfig = CorsConfig( allowedOrigin = { case origin if origin == Origin.parse("http://localhost:3000").toOption.get => Some(AccessControlAllowOrigin.Specific(origin)) case _ => None }, ) val backend: Routes[Any, Response] = Routes( Method.GET / "json" -> handler(Response.json("""{"message": "Hello World!"}""")), ) @@ cors(config) val frontend: Routes[Any, Response] = Routes( Method.GET / PathCodec.empty -> handler( Response.html( html( p("Message: ", output()), script(""" |// This runs on http://localhost:3000 |fetch("http://localhost:8080/json") | .then((res) => res.json()) | .then((res) => document.querySelector("output").textContent = res.message); |""".stripMargin), ), ), ), ) val frontEndServer = Server.serve(frontend).provide(Server.defaultWithPort(3000)) val backendServer = Server.serve(backend).provide(Server.defaultWithPort(8080)) val run = frontEndServer.zipPar(backendServer) } ``` --- ## Multipart Form Data Example ```scala title="zio-http-example/src/main/scala/example/MultipartFormData.scala" //> using dep "dev.zio::zio-http:3.4.0" package example object MultipartFormData extends ZIOAppDefault { private val routes: Routes[Any, Response] = Routes( Method.POST / "upload" -> handler { (req: Request) => if (req.header(Header.ContentType).exists(_.mediaType == MediaType.multipart.`form-data`)) for { form <- req.body.asMultipartForm .mapError(ex => Response( Status.InternalServerError, body = Body.fromString(s"Failed to decode body as multipart/form-data (${ex.getMessage}"), ), ) response <- form.get("file") match { case Some(file) => file match { case FormField.Binary(_, data, contentType, transferEncoding, filename) => ZIO.succeed( Response.text( s"Received ${data.length} bytes of $contentType filename $filename and transfer encoding $transferEncoding", ), ) case _ => ZIO.fail( Response(Status.BadRequest, body = Body.fromString("Parameter 'file' must be a binary file")), ) } case None => ZIO.fail(Response(Status.BadRequest, body = Body.fromString("Missing 'file' from body"))) } } yield response else ZIO.succeed(Response(status = Status.NotFound)) }, ).sandbox @nowarn("msg=dead code") private def program: ZIO[Client & Server, Throwable, Unit] = for { port <- Server.install(routes) _ <- ZIO.logInfo(s"Server started on port $port") client <- ZIO.service[Client] response <- client .host("localhost") .port(port) .batched( Request.post( "/upload", Body.fromMultipartForm( Form( FormField.binaryField( "file", Chunk.fromArray("Hello, world!".getBytes), MediaType.application.`octet-stream`, filename = Some("hello.txt"), ), ), Boundary("AaB03x"), ), ), ) responseBody <- response.body.asString _ <- ZIO.logInfo(s"Response: [${response.status}] $responseBody") _ <- ZIO.never } yield () override def run = program.provide(Server.default, Client.default) } ``` ## Multipart Form Data Streaming Example ```scala title="zio-http-example/src/main/scala/example/MultipartFormDataStreaming.scala" //> using dep "dev.zio::zio-http:3.4.0" //> using dep "dev.zio::zio-streams:2.1.18" package example object MultipartFormDataStreaming extends ZIOAppDefault { private val routes: Routes[Any, Response] = Routes( Method.POST / "upload-simple" -> handler { (req: Request) => for { count <- req.body.asStream.run(ZSink.count) _ <- ZIO.debug(s"Read $count bytes") } yield Response.text(count.toString) }, Method.POST / "upload-nonstream" -> handler { (req: Request) => for { form <- req.body.asMultipartForm count = form.formData.collect { case sb: FormField.Binary => sb.data.size case _ => 0 }.sum _ <- ZIO.debug(s"Read $count bytes") } yield Response.text(count.toString) }, Method.POST / "upload-collect" -> handler { (req: Request) => for { sform <- req.body.asMultipartFormStream form <- sform.collectAll count = form.formData.collect { case sb: FormField.Binary => sb.data.size case _ => 0 }.sum _ <- ZIO.debug(s"Read $count bytes") } yield Response.text(count.toString) }, Method.POST / "upload" -> handler { (req: Request) => if (req.header(Header.ContentType).exists(_.mediaType == MediaType.multipart.`form-data`)) for { _ <- ZIO.debug("Starting to read multipart/form stream") form <- req.body.asMultipartFormStream .mapError(ex => Response( Status.InternalServerError, body = Body.fromString(s"Failed to decode body as multipart/form-data (${ex.getMessage}"), ), ) count <- form.fields.flatMap { case sb: FormField.StreamingBinary => sb.data case _ => ZStream.empty }.run(ZSink.count) _ <- ZIO.debug(s"Finished reading multipart/form stream, received $count bytes of data") } yield Response.text(count.toString) else ZIO.succeed(Response(status = Status.NotFound)) }, ).sandbox @@ Middleware.debug @nowarn("msg=dead code") private def program: ZIO[Server, Throwable, Unit] = for { port <- Server.install(routes) _ <- ZIO.logInfo(s"Server started on port $port") _ <- ZIO.never } yield () override def run = program .provide( ZLayer.succeed(Server.Config.default.enableRequestStreaming), Server.live, ) } ``` --- ## Server Sent Events in Endpoints Example ```scala title="zio-http-example/src/main/scala/example/ServerSentEventEndpoint.scala" //> using dep "dev.zio::zio-http:3.4.0" //> using dep "dev.zio::zio-streams:2.1.18" package example object ServerSentEventEndpoint extends ZIOAppDefault { val sseEndpoint: Endpoint[Unit, Unit, ZNothing, ZStream[Any, Nothing, ServerSentEvent[String]], AuthType.None] = Endpoint(Method.GET / "sse") .outStream[ServerSentEvent[String]](MediaType.text.`event-stream`) .inCodec(HttpCodec.header(Header.Accept).const(Header.Accept(MediaType.text.`event-stream`))) val stream: ZStream[Any, Nothing, ServerSentEvent[String]] = ZStream.repeatWithSchedule(ServerSentEvent(ISO_LOCAL_TIME.format(LocalDateTime.now)), Schedule.spaced(1.second)) val sseRoute = sseEndpoint.implementHandler(Handler.succeed(stream)) val routes: Routes[Any, Response] = sseRoute.toRoutes @@ Middleware.requestLogging(logRequestBody = true) @@ Middleware.debug override def run = { Server.serve(routes).provide(Server.default) } } object ServerSentEventEndpointClient extends ZIOAppDefault { private val invocation : Invocation[Unit, Unit, ZNothing, ZStream[Any, Nothing, ServerSentEvent[String]], AuthType.None] = ServerSentEventEndpoint.sseEndpoint(()) override def run = ZIO .scoped(for { client <- ZIO.service[Client] executor = EndpointExecutor(client, url"http://localhost:8080") stream <- executor(invocation) _ <- stream.foreach(event => ZIO.logInfo(event.data)) } yield ()) .provide(ZClient.default) } ``` --- ## Serving Static Files Example ## Serving Static Files ```scala title="zio-http-example/src/main/scala/example/StaticFiles.scala" //> using dep "dev.zio::zio-http:3.4.0" package example object StaticFiles extends ZIOAppDefault { /** * Creates an HTTP app that only serves static files from resources via * "/static". For paths other than the resources directory, see * [[zio.http.Middleware.serveDirectory]]. */ val routes = Routes.empty @@ Middleware.serveResources(Path.empty / "static") override def run = Server.serve(routes).provide(Server.default) } ``` ## Serving Static Resource Files ```scala title="zio-http-example/src/main/scala/example/StaticServer.scala" //> using dep "dev.zio::zio-http:3.4.0" package example object StaticServer extends ZIOAppDefault { // A simple app to serve static resource files from a local directory. val app = Routes( Method.GET / "static" / trailing -> handler { val extractPath = Handler.param[(Path, Request)](_._1) val extractRequest = Handler.param[(Path, Request)](_._2) for { path <- extractPath file <- Handler.getResourceAsFile(path.encode) http <- // Rendering a custom UI to list all the files in the directory extractRequest >>> (if (file.isDirectory) { // Accessing the files in the directory val files = file.listFiles.toList.sortBy(_.getName) val base = "/static/" val rest = path // Custom UI to list all the files in the directory Handler.template(s"File Explorer ~$base${path}") { ul( li(a(href := s"$base$rest", "..")), files.map { file => li( a( href := s"$base${path.encode}${if (path.isRoot) file.getName else "/" + file.getName}", file.getName, ), ) }, ) } } // Return the file if it's a static resource else if (file.isFile) Handler.fromFile(file) // Return a 404 if the file doesn't exist else Handler.notFound) } yield http }, ).sandbox val run = Server.serve(app).provide(Server.default) } ``` --- ## Serving Static Files ```scala title="zio-http-example/src/main/scala/example/StaticFiles.scala" //> using dep "dev.zio::zio-http:3.4.0" package example object StaticFiles extends ZIOAppDefault { /** * Creates an HTTP app that only serves static files from resources via * "/static". For paths other than the resources directory, see * [[zio.http.Middleware.serveDirectory]]. */ val routes = Routes.empty @@ Middleware.serveResources(Path.empty / "static") override def run = Server.serve(routes).provide(Server.default) } ``` --- ## Streaming Examples ## Streaming Request ```scala title="zio-http-example/src/main/scala/example/RequestStreaming.scala" //> using dep "dev.zio::zio-http:3.4.0" package example object RequestStreaming extends ZIOAppDefault { // Create HTTP route which echos back the request body private val app = Routes(Method.POST / "echo" -> handler { (req: Request) => // Returns a stream of bytes from the request // The stream supports back-pressure val stream = req.body.asStream // Creating HttpData from the stream // This works for file of any size val data = Body.fromStreamChunked(stream) Response(body = data) }) // Run it like any simple app override val run: ZIO[Environment with ZIOAppArgs with Scope, Any, Any] = Server.serve(app).provide(Server.default) } ``` ## Streaming Response ```scala title="zio-http-example/src/main/scala/example/StreamingResponse.scala" //> using dep "dev.zio::zio-http:3.4.0" //> using dep "dev.zio::zio-streams:2.1.18" package example /** * Example to encode content using a ZStream */ object StreamingResponse extends ZIOAppDefault { // Starting the server (for more advanced startup configuration checkout `HelloWorldAdvanced`) def run = Server.serve(routes).provide(Server.default) // Create a message as a Chunk[Byte] def message = Chunk.fromArray("Hello world !\r\n".getBytes(Charsets.Http)) def routes: Routes[Any, Response] = Routes( // Simple (non-stream) based route Method.GET / "health" -> handler(Response.ok), // ZStream powered response Method.GET / "stream" -> handler( http.Response( status = Status.Ok, body = Body.fromStream(ZStream.fromChunk(message), message.length.toLong), // Encoding content using a ZStream ), ), ) } ``` ## Streaming File ```scala title="zio-http-example/src/main/scala/example/FileStreaming.scala" //> using dep "dev.zio::zio-http:3.4.0" package example object FileStreaming extends ZIOAppDefault { // Create HTTP route val app = Routes( Method.GET / "health" -> Handler.ok, // Read the file as ZStream // Uses the blocking version of ZStream.fromFile Method.GET / "blocking" -> Handler.fromStreamChunked(ZStream.fromPath(Paths.get("README.md"))), // Uses netty's capability to write file content to the Channel // Content-type response headers are automatically identified and added // Adds content-length header and does not use Chunked transfer encoding Method.GET / "video" -> Handler.fromFile(new File("src/main/resources/TestVideoFile.mp4")), Method.GET / "text" -> Handler.fromFile(new File("src/main/resources/TestFile.txt")), ).sandbox // Run it like any simple app val run = Server.serve(app).provide(Server.default) } ``` --- ## WebSocket Example This example shows how to create a WebSocket server using ZIO HTTP and how to write a client to connect to it. ## Server First we define a `WebSocketApp` that will handle the WebSocket connection. The `Handler.webSocket` constructor gives access to the `WebSocketChannel`. The channel can be used to receive messages from the client and send messages back. We use the `receiveAll` method, to pattern match on the different channel events that could occur. The most important events are `Read` and `UserEventTriggered`. The `Read` event is triggered when the client sends a message to the server. The `UserEventTriggered` event is triggered when the connection is established. We can identify the successful connection of a client by receiving a `UserEventTriggered(UserEvent.HandshakeComplete)` event. And if the client sends us a text message, we will receive a `Read(WebSocketFrame.Text())` event. Our WebSocketApp will handle the following events send by the client: * If the client connects to the server, we will send a "Greetings!" message to the client. * If the client sends "foo", we will send a "bar" message back to the client. * If the client sends "bar", we will send a "foo" message back to the client. * If the client sends "end", we will close the connection. * If the client sends any other message, we will send the same message back to the client 10 times. For the client to establish a connection with the server, we offer the `/subscriptions` endpoint: ```scala title="zio-http-example/src/main/scala/example/websocket/WebSocketServerAdvanced.scala" package example.websocket object WebSocketServerAdvanced extends ZIOAppDefault { val socketApp: WebSocketApp[Any] = Handler.webSocket { channel => channel.receiveAll { case Read(WebSocketFrame.Text("end")) => channel.shutdown // Send a "bar" if the client sends a "foo" case Read(WebSocketFrame.Text("foo")) => channel.send(Read(WebSocketFrame.text("bar"))) // Send a "foo" if the client sends a "bar" case Read(WebSocketFrame.Text("bar")) => channel.send(Read(WebSocketFrame.text("foo"))) // Echo the same message 10 times if it's not "foo" or "bar" case Read(WebSocketFrame.Text(text)) => channel .send(Read(WebSocketFrame.text(s"echo $text"))) .repeatN(10) .catchSomeCause { case cause => ZIO.logErrorCause(s"failed sending", cause) } // Send a "greeting" message to the client once the connection is established case UserEventTriggered(UserEvent.HandshakeComplete) => channel.send(Read(WebSocketFrame.text("Greetings!"))) // Log when the channel is getting closed case Read(WebSocketFrame.Close(status, reason)) => Console.printLine("Closing channel with status: " + status + " and reason: " + reason) // Print the exception if it's not a normal close case ExceptionCaught(cause) => Console.printLine(s"Channel error!: ${cause.getMessage}") case _ => ZIO.unit } } val routes: Routes[Any, Response] = Routes( Method.GET / "greet" / string("name") -> handler { (name: String, _: Request) => Response.text(s"Greetings ${name}!") }, Method.GET / "subscriptions" -> handler(socketApp.toResponse), ) override val run = Server.serve(routes).provide(Server.default) } ``` A few things worth noting: * `Server.default` starts a server on port 8080. * `socketApp.toResponse` converts the `WebSocketApp` to a `Response`, so we can serve it with `handler`. ## Client The client will connect to the server and send a message to the server every time the user enters a message in the console. For this we will use the `Console.readLine` method to read a line from the console. We will then send the message to the server using the `WebSocketChannel.send` method. But since we don't want to reconnect to the server every time the user enters a message, we will use a `Queue` to store the messages. We will then use the `Queue.take` method to take a message from the queue and send it to the server, whenever a new message is available. Adding a new message to the queue, as well as sending the messages to the server, should happen in a loop in the background. For this we will use the operators `forever` (looping) and `forkDaemon` (fork to a background fiber). Again we will use the `Handler.webSocket` constructor to define how to handle messages and create a `WebSocketApp`. But this time, instead of serving the `WebSocketApp` we will use the `connect` method to establish a connection to the server. All we need for that, is the URL of the server. In our case it's `"ws://localhost:8080/subscriptions"`. ```scala title="zio-http-example/src/main/scala/example/websocket/WebSocketClientAdvanced.scala" package example.websocket object WebSocketSimpleClientAdvanced extends ZIOAppDefault { def sendChatMessage(message: String): ZIO[Queue[String], Throwable, Unit] = ZIO.serviceWithZIO[Queue[String]](_.offer(message).unit) def processQueue(channel: WebSocketChannel): ZIO[Queue[String], Throwable, Unit] = { for { queue <- ZIO.service[Queue[String]] msg <- queue.take _ <- channel.send(Read(WebSocketFrame.Text(msg))) } yield () }.forever.forkDaemon.unit private def webSocketHandler: ZIO[Queue[String] with Client with Scope, Throwable, Response] = Handler.webSocket { channel => for { _ <- processQueue(channel) _ <- channel.receiveAll { case Read(WebSocketFrame.Text(text)) => Console.printLine(s"Server: $text") case _ => ZIO.unit } } yield () }.connect("ws://localhost:8080/subscriptions") override val run = { ZIO.scoped(webSocketHandler) *> Console.readLine.flatMap(sendChatMessage).forever.forkDaemon *> ZIO.never }.provide( Client.default, ZLayer(Queue.bounded[String](100)), ) } ``` While we access here `Queue[String]` via the ZIO environment, you should use a service in a real world application, that requires a queue as one of its constructor dependencies. See [ZIO Services](https://zio.dev/reference/service-pattern/) for more information. ## WebSocket Echo ```scala title="zio-http-example/src/main/scala/example/websocket/WebSocketEcho.scala" package example.websocket object WebSocketEcho extends ZIOAppDefault { private val socketApp: WebSocketApp[Any] = Handler.webSocket { channel => channel.receiveAll { case Read(WebSocketFrame.Text("FOO")) => channel.send(Read(WebSocketFrame.Text("BAR"))) case Read(WebSocketFrame.Text("BAR")) => channel.send(Read(WebSocketFrame.Text("FOO"))) case Read(WebSocketFrame.Text(text)) => channel.send(Read(WebSocketFrame.Text(text))).repeatN(10) case _ => ZIO.unit } } private val routes: Routes[Any, Response] = Routes( Method.GET / "greet" / string("name") -> handler { (name: String, _: Request) => Response.text(s"Greetings {$name}!") }, Method.GET / "subscriptions" -> handler(socketApp.toResponse), ) override val run = Server.serve(routes).provide(Server.default) } ``` --- ## Dev / Preprod / Prod Modes ZIO HTTP provides a simple built-in notion of application "mode" so you can adapt behavior (e.g. enable extra diagnostics in development, stricter settings in production, other routes, different error handling) without wiring your own config keys everywhere. The available modes are: - `Mode.Dev` (default if nothing is configured) - `Mode.Preprod` (a staging / pre‑production environment) - `Mode.Prod` (production) ## Reading the Current Mode Use any of the following helpers: ```scala // Full value def m: Mode = Mode.current // Convenience booleans val isDev = Mode.isDev val isPreprod = Mode.isPreprod val isProd = Mode.isProd ``` ## Configuring the Mode The mode is determined in this precedence order: 1. JVM System Property: `-Dzio.http.mode=` 2. Environment Variable: `ZIO_HTTP_MODE=` 3. Fallback: `dev` Examples: ```bash # Using a JVM system property sbt "run -Dzio.http.mode=preprod" # Using an environment variable (takes effect if the system property is NOT set) ZIO_HTTP_MODE=prod sbt run ``` Unknown values cause a warning on stderr and the mode falls back to `dev`. ## Typical Use Cases You can branch on the mode to enable / disable features: ```scala val extraRoutes: Routes[Any, Nothing] = if (Mode.isDev) Routes.empty // In real code: SwaggerUI.routes("docs", OpenAPIGen.empty) else Routes.empty val baseRoutes: Routes[Any, Nothing] = Routes( Method.GET / "health" -> handler(Response.ok) ) val appRoutes = baseRoutes ++ extraRoutes ``` Or adapt server config: ```scala val serverConfig = if (Mode.isProd) Server.Config.default .requestDecompression(true) else Server.Config.default // lighter config in dev ``` ## Testing Modes Inside tests you generally want to *temporarily* switch the mode to verify conditional behavior. The testkit provides aspects in `zio.http.HttpTestAspect`: - `HttpTestAspect.devMode` - `HttpTestAspect.preprodMode` - `HttpTestAspect.prodMode` Each aspect sets the mode for the duration of the test, restoring the previous mode afterward. This allows you to write tests that depend on specific modes without affecting other tests. Example: ```scala title="zio-http-example-testing/src/test/scala/example/testing/GuideModeExamplesSpec.scala" package example.testing /** * Dev/Preprod/Prod Modes — Testing Mode-Dependent Behavior * * Demonstrates how to test mode-dependent behavior using HttpTestAspect * to override the current mode for specific tests. * * Run with: sbt "zio-http-example-testing/testOnly example.testing.GuideModeExamplesSpec" */ object GuideModeExamplesSpec extends ZIOSpecDefault { def spec = suite("GuideModeExamplesSpec")( test("enables preprod logic") { assertTrue(Mode.current == Mode.Preprod) } @@ HttpTestAspect.preprodMode, test("enables prod logic") { assertTrue(Mode.isProd) } @@ HttpTestAspect.prodMode, ) @@ TestAspect.sequential // IMPORTANT: sequential to avoid mode race conditions } ``` ([source](https://github.com/zio/zio-http/blob/main/zio-http-example-testing/src/test/scala/example/testing/GuideModeExamplesSpec.scala)) ### Why `TestAspect.sequential`? The mode is stored per JVM. When you apply different mode aspects to multiple tests in the **same suite**, running them in parallel could cause races (e.g. one test reads prod while another just switched to preprod). Adding `@@ TestAspect.sequential` ensures the suite’s tests execute one after another so each mode override is isolated. If every test suite uses only one mode (or you wrap all tests in a single aspect at the suite level), sequential execution is not strictly necessary. It is required only when multiple tests in the same suite each apply different mode aspects. ## Quick Reference | Task | How | |------|-----| | Read current mode | `Mode.current` | | Check if dev | `Mode.isDev` | | Run in preprod | `-Dzio.http.mode=preprod` or `ZIO_HTTP_MODE=preprod` | | Override in a test | `test("...") { ... } @@ HttpTestAspect.prodMode` | | Avoid race conditions | Apply `@@ TestAspect.sequential` to suite when multiple mode aspects are used | ## When *Not* to Use Mode For complex environment-dependent configuration (database URLs, secrets, feature flags) prefer a dedicated configuration service (e.g. `zio-config`). --- ## Endpoint API The Endpoint API is a declarative DSL for defining HTTP endpoints. It is a way to define a type safe API for your application. It comes with batteries included and supports out of the box JSON, protobuf, plain text and binary data serialization and deserialization. It also supports automatic validation via ZIO Schema, and automatic OpenAPI documentation generation. Endpoints can be used to implement not only servers but also clients. ```scala final case class UserParams(city: String, @validate(Validation.greaterThan(17)) age: Int) object UserParams { implicit val schema: Schema[UserParams] = DeriveSchema.gen[UserParams] } val endpoint = // typed path parameter "user" Endpoint(Method.GET / "hello" / string("user")) // reads the two query parameters city and age from the request and validates the age .query(HttpCodec.query[UserParams]) // support for HTML templates included .out[Dom] /* SERVER */ // Generates OpenAPI documentation for the endpoint val openApi = OpenAPIGen.fromEndpoints("User API", "1.0.0", endpoint) // Routes for the endpoint and the Swagger UI val routes = endpoint.implement { case (user, params) => ZIO.succeed(Dom.text(s"Hello $user, you are ${params.age} years old and live in ${params.city}")) }.toRoutes ++ SwaggerUI.routes("intern" / "apidoc", openApi) /* CLIENT */ def endpointExecutor(client: Client) = EndpointExecutor(client, url"http://localhost:8080") val clientApp: ZIO[Scope with Client, Nothing, Dom] = for { client <- ZIO.service[Client] dom <- endpointExecutor(client)(endpoint("John", UserParams("New York", 25))) } yield dom ``` For more details on the Endpoint API, see the [documentation](./../reference/endpoint.md). --- ## Middleware(Concepts) A middleware has the purpose of intercepting a request, a response or both. It helps in implementing cross-cutting concerns like access logging, authentication, etc. ZIO HTTP provides a lot out-of-the-box middlewares. For example for CORS or authentication. For more details how to use middlewares, see the [middleware documentation](./../reference/aop/middleware.md). ## Handler Aspect A `HandlerAspect` is a special middleware, that can not only intercept requests and responses, but also compute values based on the request and inject it back into the request handler. This is useful for example for authentication, where the handler aspect can extract the user from the request or the database. For more details how to use handler aspects, see the [handler aspect documentation](./../reference/aop/handler_aspect.md). --- ## Routing ZIO HTTP routing does some things differently than other (Scala) HTTP libraries. This document explains the differences and the reasons behind them. ## Declarative routing ZIO HTTP uses a declarative routing DSL. This means that a data structures describe the routing logic. This is in contrast to other libraries that often use functions to describe routing logic. In the Scala world usually partial functions. The main advantage of a declarative routing is the ability to inspect the routes at runtime. This gives ZIO HTTP the ability to generate not only a lookup tree but also generate documentation. Partial functions are opaque and can't be inspected at runtime. A request must therefore in the worst case traverse all routes to find the correct one. A service with 1000 routes build on partial functions would need to check all 1000 routes just to generate a 404 response. ZIO HTTPs tree based lookup can immediately tell if a route is not present just by inspecting the first segment of the path. ## Type-safe routing Path parameters are typed in ZIO HTTP. So a segment that is a variable must have a type. If a request does not match the type the route is not considered a match. ZIO HTTP will then reject the request automatically. The user defined handler will not be called. ```scala val routes = Routes( Method.GET / "hello" / string("name") -> handler { (name: String, _: Request) => Response.text(s"Hello $name") } ) ``` For more details and code examples see the [routing pattern documentation](./../reference/routing/route_pattern.md). ## Query Parameters are not part of the routing Query parameters are not part of the routing. They are part of the request handling. --- ## RC4 To Xx **QueryCodec** - removed methods that start with `param` use methods starting with `query` instead - renamed `queryAs` to `queryTo` **QueryParam** - renamed all methods that return typed params from `as` to `to` - to align with header and to offer operations for query parameters directly on the `Request`, the methods are now called `queryParam` instead of `get` and `queryParams` instead of `getAll` **URL** - renamed `queryParams` to `setQueryParams` --- ## RC6 To Xx **`Root` and `Empty`** - replace `Root` with `Path.root` - replace `Empty` with `Path.empty` --- ## Frequently Asked Questions ### I'm New to ZIO! How Can I Get Started with ZIO HTTP? If you are new to ZIO, you can start by reading the [ZIO documentation](https://zio.dev/overview/getting-started) to understand the core concepts of ZIO. Once you are comfortable with ZIO, you can explore the ZIO HTTP documentation to learn how to build HTTP applications using ZIO. There are also several [examples](https://github.com/zio/zio-http/tree/main/zio-http-example/src/main/scala/example) available in the ZIO HTTP repository that help you get started quickly. ### What Libraries Does ZIO HTTP Rely On? ZIO HTTP is built on top of [Netty](https://netty.io/) for networking operations. It also leverages [ZIO Core](https://zio.dev/reference/core/zio/), [ZIO Schema](https://zio.dev/zio-schema/), and [ZIO Stream](https://zio.dev/reference/stream/) for concurrency, encoding/decoding, and streaming, respectively. ### I Love ZIO, But Don't Want to Use ZIO HTTP. What Alternatives Do I Have? If you prefer not to use ZIO HTTP but still want to build HTTP applications using ZIO, you can consider using [Http4s](https://http4s.org/), a functional HTTP library for Scala that can be integrated with ZIO effects ([link](https://github.com/zio/zio-json/tree/series/2.x/examples/interop-http4s)). Additionally, if you are looking for a declarative approach to building HTTP applications, similar to ZIO HTTP's `Endpoint` API, you can explore [Tapir](https://tapir.softwaremill.com/). It's easy to integrate Tapir with ZIO using [tapir-zio-http-server](https://tapir.softwaremill.com/en/latest/server/ziohttp.html) module. ### How Can I Serialize/Deserialize Data to/from JSON in Requests and Responses? ZIO HTTP provides built-in support for JSON serialization and deserialization using [ZIO Schema](https://zio.dev/zio-schema/). You can derive JSON codecs for your custom data types using ZIO Schema and use them to encode/decode data to/from request/response bodies. Check out the [BinaryCodecs](./reference/body/binary_codecs.md) section in the documentation for more details. ### How Can I Handle CORS Requests in ZIO HTTP? ZIO has several middlewares including `CORS` that can be used to handle cross-origin resource sharing requests. Check out the [Middleware](./reference/aop/middleware.md) section in the documentation for more details. ### How Does ZIO HTTP Handle Errors? As a ZIO-based library, ZIO HTTP leverages ZIO's error-handling capabilities to handle errors in HTTP applications. You can use the `ZIO#catch**` methods to handle errors at various levels in your application. If you are not familiar with error handling in ZIO, we recommend reading the [ZIO documentation](https://zio.dev/reference/error-management/). Besides ZIO's error handling, ZIO HTTP has built-in support for handling errors. It has a notion of handled and unhandled errors at the HTTP layer. Let's say we have a `Handler[-R, +Err, Request, Response]`, if the error channel is `Response` or a sub-type of it, it is considered as handled error, otherwise, it is considered as a handler with the unhandled error. We have the same thing at the route level. Let's say we have a `Routes[-Env, +Err]`, if the error channel is `Response` or a sub-type of it, it is considered as a collection of routes with all the errors handled, otherwise, it is considered as a collection of routes with unhandled errors. Before we can serve all routes, we should convert all unhandled errors to handled errors, using the `Routes#handlerError` method. ### Is ZIO HTTP Suitable for Building High-performance HTTP Servers? Yes, ZIO HTTP is designed for performance, leveraging non-blocking I/O and asynchronous concurrency to handle high loads efficiently. 1. It is [Netty](https://netty.io/)-based, which is a proven, high-performance, asynchronous, event-driven network application framework. You can optimize Netty's performance by tuning various parameters, such as changing leak detection levels, adjusting the [channel type](reference/server.md#netty-configuration) (NIO, Epoll, KQueue, or io_uring), and configuring the number of threads. 2. It uses [ZIO's fiber-based concurrency model](https://zio.dev/reference/fiber/), which allows for lightweight and efficient handling of concurrent requests. 3. It has streaming support using [ZIO Stream](https://zio.dev/reference/stream/), enabling efficient handling of large data transfers without consuming excessive memory. 4. You can compare its performance with other Scala HTTP frameworks using benchmarks like the [TechEmpower Framework Benchmarks](https://www.techempower.com/benchmarks/). It is listed in two benchmark categories of TechEmpower: - [JSON Serialization](https://www.techempower.com/benchmarks/#section=data-r23&test=json) - [Plaintext](https://www.techempower.com/benchmarks/#section=data-r23&test=plaintext) ### Can I integrate ZIO HTTP with Existing Scala/Java Libraries? Yes, ZIO HTTP provides interoperability with existing libraries, allowing you to leverage functionality from the Scala/Java ecosystem seamlessly by importing blocking and non-blocking operations. ### Can I use the Netty io_uring incubator transport? Yes there is a server configuration for this transport - but this transport is experimental so you must explicitly add the library in your project dependencies. Some Netty based libraries use transports like this by default if they are on the classpath - so make sure if you are using Netty elsewhere in your project that the library will not cause unintended channel type selection. An example of such a Netty based library is [lettuce](https://github.com/redis/lettuce/issues/3222) and there may be others like this as well. You can find the uring channel type library [here](https://github.com/netty/netty-incubator-transport-io_uring). ### Is ZIO HTTP Suitable for Building Microservices? Yes, ZIO HTTP along with the ZIO ecosystem is well-suited for building microservices, which provides many aspects that are essential for building cloud-native applications: - **Configuration**- ZIO has a [built-in configuration system](https://zio.dev/reference/configuration/) that allows you to manage different configurations for different environments. It also has [ZIO Config](https://zio.dev/zio-config/) that provides various config providers to load configurations from different sources, such as HOCON, JSON, YAML, and environment variables. - **Logging and LogAnnotations**- ZIO provides a [structured logging system](https://zio.dev/reference/observability/logging) that allows you to log messages with different log levels. You can also use log annotations to add additional context to log messages which can be useful for debugging and tracing in distributed systems. We can use any of the backend logging supported by [ZIO Logging](https://zio.dev/zio-logging/), such as Log4j, Logback, and more. - **Distributed Tracing**- In microservice architectures, distributed tracing is essential for monitoring the flow of requests across different services. [ZIO Telemetry](https://zio.dev/zio-telemetry/) supports distributed tracing using OpenTelemetry, OpenTracing, and OpenCensus. - **Instrumenting Metrics**- ZIO has [built-in support for metrics instrumentation](https://zio.dev/reference/observability/metrics/), with popular [metrics backends](https://zio.dev/zio-metrics-connectors/) such as Prometheus, Datadog, New Relic, and more. - **Resilience to failures**- When building microservices, it is essential to handle failures gracefully. There is a project called [Rezilience](https://zio.dev/ecosystem/community/) that provides various resilience patterns such as retries, timeouts, circuit breakers, rate limiting, and more to build robust and resilient microservices. - **Concurrency and Parallelism**- ZIO provides a powerful concurrency model that allows you to write highly concurrent and parallel applications. With [ZIO Fiber](https://zio.dev/reference/concurrency/fiber/), and its [Concurrency Primitives](https://zio.dev/reference/concurrency/#concurrency-primitives), you can write highly concurrent applications. - **Resource-safety** - ZIO provides a resource system that ensures resources are acquired and released safely. With [ZIO Scopes](https://zio.dev/reference/resource/scope/) and also [scoped Layers](https://zio.dev/reference/resource/scope/#converting-resources-into-other-zio-data-types), we can manage resources in a structured way. - **ZIO Aspect and Middlewares**- Both ZIO and ZIO HTTP support the idea of aspects and middlewares, which can be used to add cross-cutting concerns such as logging, metrics, authentication, and more to your services. - **Modularity**- With [service pattern](https://zio.dev/reference/service-pattern/) in ZIO, we can define our services along with their dependencies, and finally, we can start the service with its dependencies. This pattern is useful for building modular and testable services.