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.
ZIO Quartz H2 defines several key types for working with HTTP routes:
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]]
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]]
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]]
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
)
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"))
Extract values from path segments using built-in extractors:
// Extract an integer from the path
case GET -> Root / "users" / IntVar(userId) =>
ZIO.succeed(Response.Ok().asText(s"User ID: $userId"))
// Extract a long from the path
case GET -> Root / "posts" / LongVar(postId) =>
ZIO.succeed(Response.Ok().asText(s"Post ID: $postId"))
// Extract a UUID from the path
case GET -> Root / "sessions" / UUIDVar(sessionId) =>
ZIO.succeed(Response.Ok().asText(s"Session ID: $sessionId"))
// Extract a string from the path
case GET -> Root / "users" / StringVar(username) =>
ZIO.succeed(Response.Ok().asText(s"Username: $username"))
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()))
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"))
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}!"))
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"))
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 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))
}
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
}
The Routes.of
function is the core mechanism for creating routes in ZIO Quartz H2. It performs a critical transformation:
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))
}
}
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:
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
)
}
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()))
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")
)
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))
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)))
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: