PathCodec
PathCodec[A]
represents a codec for paths of type A
, comprising segments where each segment can be a literal, an integer, a long, a string, a UUID, or the trailing path.
The three basic operations that PathCodec
supports are:
- decode: converting a path into a value of type
A
. - format: converting a value of type
A
into a path. - ++ or /: combining two
PathCodec
values to create a newPathCodec
that matches both paths, so the resulting of the decoding operation will be a tuple of the two values.
So we can think of PathCodec
as the following simplified trait:
trait PathCodec[A] {
def /[B](that: PathCodec[B]): PathCodec[(A, B)]
def decode(path: Path): Either[String, A]
def format(value: A): : Either[String, Path]
}
Building PathCodecs
The PathCodec
data type offers several predefined codecs for common types:
PathCodec | Description |
---|---|
PathCodec.bool | A codec for a boolean path segment. |
PathCodec.empty | A codec for an empty path. |
PathCodec.literal | A codec for a literal path segment. |
PathCodec.long | A codec for a long path segment. |
PathCodec.string | A codec for a string path segment. |
PathCodec.uuid | A codec for a UUID path segment. |
Complex PathCodecs
can be constructed by combining them using the /
operator:
import zio.http.codec.PathCodec
import PathCodec._
val pathCodec = empty / "users" / int("user-id") / "posts" / string("post-id")
By combining PathCodec
values, the resulting PathCodec
type reflects the types of the path segments it matches. In the provided example, the type of pathCodec
is (Int, String)
because it matches a path with two segments of type Int
and String
, respectively.
Decoding and Formatting PathCodecs
To decode a path into a value of type A
, we can use the PathCodec#decode
method:
import zio.http._
pathCodec.decode(Path("users/123/posts/abc"))
// res0: Either[String, (Int, String)] = Right(value = (123, "abc"))
To format (encode) a value of type A
into a path, we can use the PathCodec#format
method:
pathCodec.format((123, "abc"))
// res1: Either[String, Path] = Right(
// value = Path(flags = 1, segments = IndexedSeq("users", "123", "posts", "abc"))
// )
Rendering PathCodecs
If we render the previous PathCodec
to a string using PathCodec#render
or PathCodec#toString
, we get the following result:
pathCodec.render
// res2: String = "/users/{user-id}/posts/{post-id}"
pathCodec.toString
// res3: String = "/users/{user-id}/posts/{post-id}"
Attaching Documentation to PathCodecs
The PathCodec#??
operator, takes a Doc
and annotate the PathCodec
with it. It is useful for generating developer-friendly documentation for the API:
import zio.http.codec._
val users = PathCodec.literal("users") ?? (Doc.p("Managing users including CRUD operations"))
// users: PathCodec[Unit] = Annotated(
// codec = Segment(segment = Literal(value = "users")),
// annotations = IndexedSeq(
// Documented(
// value = Paragraph(
// value = Text(value = "Managing users including CRUD operations")
// )
// )
// )
// )
When generating OpenAPI documentation, these annotations will be used to generate the API documentation.
Attaching Examples to PathCodecs
Similarly to attaching documentation, we can attach examples to PathCodec
using the PathCodec#example
operator:
import zio.http.codec._
val userId = PathCodec.int("user-id") ?? (Doc.p("The user id")) example ("user-id", 123)
// userId: PathCodec[Int] = Annotated(
// codec = Segment(segment = IntSeg(name = "user-id")),
// annotations = IndexedSeq(
// Documented(value = Paragraph(value = Text(value = "The user id"))),
// Examples(examples = Map("user-id" -> 123))
// )
// )
Using Value Objects with PathCodecs
Other than the common PathCodec
constructors, it's also possible to transform a PathCodec
into a more specific data type using the transform
method.
This becomes particularly useful when adhering to domain-driven design principles and opting for value objects instead of primitive types:
import zio.http.codec.PathCodec
import PathCodec._
case class UserId private(value: Int)
object UserId {
def apply(value: Int): UserId =
if (value > 0)
new UserId(value)
else
throw new IllegalArgumentException("User id must be positive")
}
val userIdPathCodec: PathCodec[UserId] = int("user-id").transform(UserId.apply)(_.value)
This approach enables us to utilize the UserId
value object in our routes, and the PathCodec
will take care of the conversion between the path segment and the value object.
In the previous example, instead of throwing an exception, we can model the failure using the Either
data type and then use the transformOrFailLeft
to create a PathCodec
:
import zio.http.codec.PathCodec
import PathCodec._
case class UserId private(value: Int)
object UserId {
def apply(value: Int): Either[String, UserId] =
if (value > 0)
Right(new UserId(value))
else
Left("User id must be positive")
}
val userIdPathCodec: PathCodec[UserId] = int("user-id").transformOrFailLeft(UserId.apply)(_.value)
Here is a list of the available transformation methods:
trait PathCodec[A] {
def transform[A2](f: A => A2)(g: A2 => A): PathCodec[A2]
def transformOrFail[A2](f: A => Either[String, A2])(g: A2 => Either[String, A]): PathCodec[A2]
def transformOrFailLeft[A2](f: A => Either[String, A2])(g: A2 => A): PathCodec[A2]
def transformOrFailRight[A2](f: A => A2)(g: A2 => Either[String, A]): PathCodec[A2]
}
Here is a complete example:
import zio._
import zio.http._
import zio.Cause.{Die, Stackless}
import zio.http.codec.PathCodec
object Main extends ZIOAppDefault {
import zio.http.codec.PathCodec
import PathCodec._
case class UserId private (value: Int)
object UserId {
def apply(value: Int): Either[String, UserId] =
if (value > 0)
Right(new UserId(value))
else
Left("User id must be greater than zero")
}
val userId: PathCodec[UserId] = int("user-id").transformOrFailLeft(UserId.apply)(_.value)
val routes: Routes[Any, Response] =
Routes(
Method.GET / "users" / userId ->
Handler.fromFunctionHandler[(UserId, Request)] { case (userId: UserId, request: Request) =>
Handler.text(userId.value.toString)
},
).handleErrorCause { case Stackless(cause, _) =>
cause match {
case Die(value, _) =>
if (value.getMessage == "User id must be greater than zero")
Response.badRequest(value.getMessage)
else
Response.internalServerError
}
}
def run = Server.serve(routes).provide(Server.default)
}
Trailing Path Segments
Sometimes, there may be a need to match a path with a trailing segment, regardless of the number of segments it contains. This is where the trailing codec comes into play:
import zio._
import zio.http._
object TrailingExample extends ZIOAppDefault {
def staticFileHandler(path: Path): Handler[Any, Throwable, Request, Response] =
for {
file <- Handler.getResourceAsFile(path.encode)
http <-
if (file.isFile)
Handler.fromFile(file)
else
Handler.notFound
} yield http
val routes =
Routes(
Method.GET / "static" / trailing ->
Handler.fromFunctionHandler[(Path, Request)] { case (path: Path, _: Request) =>
staticFileHandler(path).contramap[(Path, Request)](_._2)
},
).sandbox @@ HandlerAspect.requestLogging()
val run = Server.serve(routes).provide(Server.default)
}
In the provided example, if an incoming request matches the route pattern GET /static/*
, the trailing
codec will match the remaining path segments and bind them to the Path
type. Therefore, a request to /static/foo/bar/baz.txt
will match the route pattern, and the Path
will be foo/bar/baz.txt
.