ZIO Quartz H2 Routes DSL

Introduction

ZIO Quartz H2 provides a powerful and expressive DSL (Domain Specific Language) for defining HTTP routes in a type-safe and functional manner. This documentation covers all aspects of the Routes DSL, from basic path matching to advanced query parameter extraction.

The Routes DSL is designed to work seamlessly with ZIO, allowing you to leverage ZIO's powerful effect system for handling HTTP requests and responses.

Core Concepts

Route Types

ZIO Quartz H2 defines several key types for working with HTTP routes:

HttpRoute[Env]

A function that takes a Request and returns a ZIO effect that might produce a Response.

type HttpRoute[Env] = Request => ZIO[Env, Throwable, Option[Response]]

WebFilter[Env]

A function that takes a Request and returns a ZIO effect that produces either a modified Request or a Response.

type WebFilter[Env] = Request => ZIO[Env, Throwable, Either[Response, Request]]

HttpRouteIO[Env]

A partial function that matches specific requests and produces a ZIO effect that yields a Response. This is the primary type you'll work with when defining routes.

type HttpRouteIO[Env] = PartialFunction[Request, ZIO[Env, Throwable, Response]]

Creating Routes

Routes are created using the Routes.of method, which takes a route definition and a filter:

// Define a route with a filter
val route = Routes.of[Any](
  pf = {
    case GET -> Root => ZIO.succeed(Response.Ok())
    case GET -> Root / "hello" => ZIO.succeed(Response.Ok().asText("Hello World!"))
  },
  filter = (r: Request) => ZIO.succeed(Right(r)) // Simple pass-through filter
)

Path Matching

Basic Path Matching

The Routes DSL provides pattern matching for HTTP paths using the / extractor:

// Match the root path
case GET -> Root => ZIO.succeed(Response.Ok())

// Match a single path segment
case GET -> Root / "users" => ZIO.succeed(Response.Ok().asText("All users"))

// Match multiple path segments
case GET -> Root / "users" / "profile" => ZIO.succeed(Response.Ok().asText("User profile"))

Path Variables

Extract values from path segments using built-in extractors:

Integer Path Variables

// Extract an integer from the path
case GET -> Root / "users" / IntVar(userId) => 
  ZIO.succeed(Response.Ok().asText(s"User ID: $userId"))

Long Path Variables

// Extract a long from the path
case GET -> Root / "posts" / LongVar(postId) => 
  ZIO.succeed(Response.Ok().asText(s"Post ID: $postId"))

UUID Path Variables

// Extract a UUID from the path
case GET -> Root / "sessions" / UUIDVar(sessionId) => 
  ZIO.succeed(Response.Ok().asText(s"Session ID: $sessionId"))

String Path Variables

// Extract a string from the path
case GET -> Root / "users" / StringVar(username) => 
  ZIO.succeed(Response.Ok().asText(s"Username: $username"))

Advanced Path Matching

Matching Path Prefixes

The /: extractor allows you to match a path prefix and extract the remaining path:

// Match a path prefix and extract the remaining path
case req @ GET -> "api" /: remainingPath => 
  ZIO.succeed(Response.Ok().asText(remainingPath.toString()))

Matching from Root

The /^ extractor allows you to match from the root and extract the entire path:

// Match from root and extract the entire path
case GET -> /^(Root, path) => 
  ZIO.succeed(Response.Ok().asText(s"Full path: $path"))

Query Parameters

Extracting Query Parameters

The :? extractor allows you to extract query parameters from a request:

// Define a query parameter extractor
val name = new QueryParam("name")

// Extract a query parameter
case GET -> Root / "hello" :? name(userName) => 
  ZIO.succeed(Response.Ok().asText(s"Hello, ${if(userName.isEmpty) "World" else userName}!"))

Multiple Query Parameters

You can extract multiple query parameters by chaining the :? extractor:

// Define query parameter extractors
val name = new QueryParam("name")
val age = new QueryParam("age")
val sort = new QueryParam("sort")
// Extract multiple query parameters
case GET -> Root / "users" :? name(userName) :? age(userAge) => 
  ZIO.succeed(Response.Ok().asText(s"Name: $userName, Age: $userAge"))

HTTP Methods

Matching HTTP Methods

The Routes DSL supports matching on HTTP methods using pattern matching:

// GET request
case GET -> Root / "users" => ZIO.succeed(Response.Ok().asText("Get all users"))

// POST request
case POST -> Root / "users" => ZIO.succeed(Response.Created().asText("User created"))

// PUT request
case PUT -> Root / "users" / IntVar(id) => ZIO.succeed(Response.Ok().asText(s"User $id updated"))

// DELETE request
case DELETE -> Root / "users" / IntVar(id) => ZIO.succeed(Response.Ok().asText(s"User $id deleted"))

// PATCH request
case PATCH -> Root / "users" / IntVar(id) => ZIO.succeed(Response.Ok().asText(s"User $id partially updated"))

// OPTIONS request
case OPTIONS -> Root / "users" => ZIO.succeed(Response.Ok().asText("Available methods: GET, POST, PUT, DELETE"))

Web Filters

Creating Web Filters

Web filters allow you to intercept and modify requests before they reach your routes, or short-circuit the request handling by returning a response directly:

// Simple pass-through filter
val passThrough: WebFilter[Any] = (r: Request) => 
  ZIO.succeed(Right(r))

// Authentication filter
val authFilter: WebFilter[Any] = (r: Request) => {
  val authHeader = r.headers.get("Authorization")
  if (authHeader.isDefined && authHeader.get.startsWith("Bearer ")) {
    ZIO.succeed(Right(r))
  } else {
    ZIO.succeed(Left(Response.Error(StatusCode.Unauthorized).asText("Unauthorized")))
  }
}

// Logging filter
val loggingFilter: WebFilter[Any] = (r: Request) => {
  ZIO.debug(s"Request: ${r.method} ${r.uri}") *> ZIO.succeed(Right(r))
}

Combining Filters

You can combine multiple filters to create a filter chain:

// Combine filters
val combinedFilter: WebFilter[Any] = (r: Request) => {
  for {
    result1 <- loggingFilter(r)
    result2 <- result1 match {
      case Right(req) => authFilter(req)
      case Left(resp) => ZIO.succeed(Left(resp))
    }
  } yield result2
}

Understanding Routes.of

Function Lifting

The Routes.of function is the core mechanism for creating routes in ZIO Quartz H2. It performs a critical transformation:

Function Lifting

Routes.of lifts your partial function (HttpRouteIO[Env]) into a total function (HttpRoute[Env]) by handling the cases where your partial function is not defined.

This lifting process transforms:

// Simplified implementation of Routes.of
def of[Env](pf: HttpRouteIO[Env], filter: WebFilter[Env]): HttpRoute[Env] = {
  val route = (request: Request) =>
    pf.lift(request) match {
      case Some(c) => c.flatMap(r => (ZIO.succeed(Option(r))))
      case None    => (ZIO.succeed(None))
    }
  (r0: Request) =>
    filter(r0).flatMap {
      case Right(request) => route(request)
      case Left(response) =>
        ZIO.logWarning(s"Web filter denied acess with response code ${response.code}") *> ZIO.succeed(Some(response))
    }
}

Key Benefits

  • Simplicity: You only need to define the routes you care about using pattern matching
  • Type Safety: The compiler ensures your route handlers return the correct types
  • Composition: Multiple partial functions can be combined using orElse
  • Filtering: WebFilters are applied before route matching, allowing for pre-processing and security checks

Composing Routes with orElse

One of the powerful features of partial functions is that they can be composed using orElse. This allows you to organize your routes into logical groups and combine them:

// User-related routes
val userRoutes: HttpRouteIO[Any] = {
  case GET -> Root / "users" => 
    ZIO.succeed(Response.Ok().asText("List of users"))
  case GET -> Root / "users" / IntVar(id) => 
    ZIO.succeed(Response.Ok().asText(s"User details for ID: $id"))
}

// Product-related routes
val productRoutes: HttpRouteIO[Any] = {
  case GET -> Root / "products" => 
    ZIO.succeed(Response.Ok().asText("List of products"))
  case GET -> Root / "products" / IntVar(id) => 
    ZIO.succeed(Response.Ok().asText(s"Product details for ID: $id"))
}

// Admin routes with different environment requirement
val adminRoutes: HttpRouteIO[AdminService] = {
  case GET -> Root / "admin" / "dashboard" => 
    ZIO.serviceWithZIO[AdminService](_.getDashboardData).map(data => 
      Response.Ok().asText(s"Admin dashboard: $data")
    )
}

// Combine routes - note that they can have different environment requirements
val combinedRoutes = userRoutes.orElse(productRoutes)

// Create the final HttpRoute with a filter
val routes = Routes.of[Any](
  pf = combinedRoutes,
  filter = (r: Request) => ZIO.succeed(Right(r))
)

// Admin routes would be provided separately with their required environment
val adminHttpRoutes = Routes.of[AdminService](
  pf = adminRoutes,
  filter = (r: Request) => ZIO.succeed(Right(r))
)

This approach allows you to:

  • Organize routes by feature or resource type
  • Reuse route definitions across different parts of your application
  • Combine routes with different environment requirements
  • Maintain separation of concerns in your codebase

Complete Example

User Management API

Here's a complete example of a user management API using the Routes DSL:

import zio.{ZIO, Task}
import io.quartz.http2.model.{Request, Response, Headers, StatusCode, Method}
import io.quartz.http2.routes.Routes
import io.quartz.http2._

object UserApi {
  // Query parameter extractors
  val name = new QueryParam("name")
  val age = new QueryParam("age")
  val sort = new QueryParam("sort")
  
  // Simple authentication filter
  val authFilter: WebFilter[Any] = (r: Request) => {
    val authHeader = r.headers.get("Authorization")
    if (authHeader.isDefined && authHeader.get.startsWith("Bearer ")) {
      ZIO.succeed(Right(r))
    } else {
      ZIO.succeed(Left(Response.Error(StatusCode.Unauthorized).asText("Unauthorized")))
    }
  }
  
  // Define routes
  val routes = Routes.of[Any](
    pf = {
      // Get all users with optional filtering and sorting
      case GET -> Root / "users" :? name(userName) :? age(userAge) :? sort(sortBy) =>
        ZIO.succeed(Response.Ok().asText(
          s"Users filtered by name: $userName, age: $userAge, sorted by: $sortBy"
        ))
      
      // Get user by ID
      case GET -> Root / "users" / IntVar(userId) =>
        ZIO.succeed(Response.Ok().asText(s"User details for ID: $userId"))
      
      // Create new user
      case POST -> Root / "users" =>
        ZIO.succeed(Response.Created().asText("User created successfully"))
      
      // Update user
      case PUT -> Root / "users" / IntVar(userId) =>
        ZIO.succeed(Response.Ok().asText(s"User $userId updated successfully"))
      
      // Delete user
      case DELETE -> Root / "users" / IntVar(userId) =>
        ZIO.succeed(Response.Ok().asText(s"User $userId deleted successfully"))
      
      // User profile section
      case GET -> Root / "users" / IntVar(userId) / "profile" =>
        ZIO.succeed(Response.Ok().asText(s"Profile for user $userId"))
      
      // User settings section with UUID
      case GET -> Root / "users" / IntVar(userId) / "settings" / UUIDVar(settingId) =>
        ZIO.succeed(Response.Ok().asText(s"Setting $settingId for user $userId"))
      
      // Fallback for unmatched routes
      case _ =>
        ZIO.succeed(Response.Error(StatusCode.NotFound).asText("Route not found"))
    },
    filter = authFilter
  )
}

Real-World Examples

Path Prefix Extraction

Extract and use the remaining path after a prefix:

// Match a path prefix and extract the remaining path
case req @ GET -> "pub" /: remainig_path =>
  ZIO.succeed(Response.Ok().asText(remainig_path.toString()))

Working with Headers and Cookies

Set custom headers and cookies in your responses:

case GET -> Root / "hello" / "user" / StringVar(userId) :? param1(par) =>
  val headers = Headers("procid" -> "header_value_from_server", "content-type" -> ContentType.Plain.toString)
  val c1 = Cookie("testCookie1", "ABCD", secure = true)
  val c2 = Cookie("testCookie2", "ABCDEFG", secure = false)
  val c3 = Cookie("testCookie3", "1A8BD0FC645E0", secure = false, expires = Some(java.time.ZonedDateTime.now.plusHours(5)))

  ZIO.succeed(
    Response
      .Ok()
      .hdr(headers)
      .cookie(c1)
      .cookie(c2)
      .cookie(c3)
      .asText(s"$userId with para1 $par")
  )

Server Name Indication (SNI) Matching

Match routes based on the SNI hostname from TLS:

// Match based on SNI hostname
case "localhost" ! GET -> Root / "example" =>
  // Send data in separate H2 packets of various size
  val ts = ZStream.fromChunks(Chunk.fromArray("Block1\n".getBytes()), Chunk.fromArray("Block22\n".getBytes()))
  ZIO.attempt(Response.Ok().asStream(ts))

File Handling

Serve files from the filesystem with proper content type detection:

case GET -> Root / StringVar(file) =>
  val FOLDER_PATH = "/home/ols/web_root/"
  val FILE = s"$file"
  val BLOCK_SIZE = 1024 * 14
  for {
    jpath <- ZIO.attempt(new java.io.File(FOLDER_PATH + FILE))
    present <- ZIO.attempt(jpath.exists())
    _ <- ZIO.fail(new java.io.FileNotFoundException(jpath.toString())).when(present == false)
  } yield (Response
    .Ok()
    .asStream(ZStream.fromFile(jpath, BLOCK_SIZE))
    .contentType(ContentType.contentTypeFromFileName(FILE)))

Advanced Filters

Path-Based Filtering with Header Injection

Filter requests based on path and add custom headers to valid requests:

val filter: WebFilter[Any] = (request: Request) =>
  ZIO.attempt(
    Either.cond(
      !request.uri.getPath().endsWith("na.txt"),
      request.hdr("test_tid" -> "ABC123Z9292827"),
      Response.Error(StatusCode.Forbidden).asText("Denied: " + request.uri.getPath())
    )
  )

This filter:

  • Rejects requests ending with "na.txt" with a 403 Forbidden response
  • For all other requests, adds a custom header "test_tid" with value "ABC123Z9292827"
  • Uses Either.cond for clean, functional conditional logic

Best Practices

Route Organization

  • Group related routes: Organize routes by functionality or resource type.
  • Use filters effectively: Apply filters for cross-cutting concerns like authentication, logging, and error handling.
  • Handle errors gracefully: Use ZIO's error handling capabilities to manage exceptions.
  • Validate input: Validate path variables and query parameters before processing.
  • Use meaningful status codes: Return appropriate HTTP status codes for different scenarios.
  • Document your API: Provide clear documentation for your routes and their expected inputs and outputs.
  • Test your routes: Write tests for your routes to ensure they behave as expected.