HTTP
The HTTP surface is the one you already know how to hold: Request, Response, Routes,
Handler, Middleware, Server, Client. Bodies on the wire are streams. body.utf8 and
body.collect read any body. body.text and body.strict are Some only for a body already in
memory, and None for a stream, never a throw. maxBodyBytes is a cap, not a buffer.
If you are building a hub, start at The hub and come back here for the knobs.
Routes and the path DSL
Method.GET / "users" / int("id") is a PathCodec. int, long, string, uuid, and
trailing are the captures. Unmatched paths are 404; wrong method is 405.
{
val routes = Routes(
Method.GET / "users" / int("id") -> { id =>
ZIO.succeed(Response.text(id.toString))
}
)
for
ok <- routes(Request.get("/users/3"))
body <- ok.body.utf8
miss <- routes(Request.get("/users/ada"))
yield (body, miss.status)
}(3,Status(404,Not Found))A capture is a name and a type, written left to right. -> takes those captures in that order, and the
parameter names are the names in the path. long("left") / long("right") is (left, right) =>. The names
are part of the check, so two captures of one type cannot be swapped and still compile. (right, left) does
not compile. _ ignores that position. It does not invent a name.
{
val routes = Routes(
Method.GET / "p" / long("left") / "x" / long("right") -> { (left, right) =>
ZIO.succeed(Response.text(s"$left $right"))
}
)
routes(Request.get("/p/1/x/2")).flatMap(_.body.utf8)
}1 2The request is not a capture. handle takes the same captures and then the request. A route that does not
read the request stays on -> and does not mention it.
{
val routes = Routes(
Method.PATCH / "p" / long("left") / long("right") handle { (left, right, req) =>
ZIO.succeed(Response.text(s"$left $right ${req.method.render}"))
}
)
routes(Request(Method.PATCH, Url.parse("/p/1/2"))).flatMap(_.body.utf8)
}1 2 PATCH "shows" / int("id") /shows/1 match id = 1
handler runs
Middleware
@@ wraps this Routes value. ++ tries the right side when the left has no matching route.
A response the left produced stands, including a 404 or a 405.
Gzip, decompress, CORS, auth, and Middleware.timeout attach to whoever you wrap.
Global gzip is all @@ Middleware.compress(), which is still wrapping routes, not a
server flag. Partial gzip is (api ++ files) @@ Middleware.compress() ++ sse.
Why that split exists is on What stays open. Metrics and traces are middleware that runs on a client too: Observability.
{
val routes =
Routes(Method.GET / "x" -> Handler.text("ok")) @@ Middleware.requestId()
routes(Request.get("/x")).map { res =>
res.header("X-Request-Id").exists(_.nonEmpty)
}
}trueroutes @@ (Middleware.requestId() ++ Middleware.cors()) request → cors → requestId → handler cors is outer. requestId still stamps X-Request-Id.
Server
Server.install / Server.serve are ordinary ZIO fibers on every platform. The JVM runs
connections on virtual threads and falls back to the default executor where it cannot. JS is
Node net / tls. Native is POSIX sockets plus OpenSSL. TLS is in the type:
Server.serveTls(app).provide(Server.Config.defaults, Tls.pem(certPem, keyPem)) needs a Tls,
and Server.install(routes, config, tls) takes one.
The layer reads the PEM at startup on every platform, so a missing block is a TlsError
before the first connection, not a failed handshake later.
HTTP/2 (ALPN h2) is the JVM bind. JS and Native serve HTTP/1.1 on that same Routes
value. Idle, header, and connection caps live on Server.Config. See
What stays open.
{
val routes = Routes(Method.GET / "health" -> Handler.text("ok"))
ZIO.scoped {
Server
.install(routes, Server.Config.default.copy(host = "127.0.0.1", port = Port(0)))
.flatMap { server =>
server.port.flatMap { port =>
Client.get(s"http://127.0.0.1:$port/health").flatMap { res =>
res.body.utf8.map((res.status, _))
}
}
}
}
}(Status(200,OK),ok)Client
Client.batched sends a request and reads the whole body. Client.streaming returns once the
head arrives and reads the body from the connection until its Scope closes. Client.sse is a
text/event-stream GET. All of them fail with ClientError, a closed enum of transport facts:
InvalidTarget, Connect, ConnectTimeout, Tls, ReadTimeout, Io, Protocol,
PoolExhausted, NotAnEventStream, InvalidTrust, BodyFailed. A response with any status is
a success. The cases carry data: InvalidTarget holds a TargetError (NoHost, NotHttp, or a
Malformed URL with its UrlError), and Protocol holds the HttpError, down to the
WireError that named which byte broke the framing.
JVM and Native share one pooled HTTP/1.1 client. Every exchange settles its connection on
success, failure, and interruption, so a timed-out call gives its slot back. A close-delimited
body never returns to the pool. A GET, HEAD, PUT, DELETE, OPTIONS, or TRACE whose pooled
connection the server already dropped is sent once more on a fresh one; a POST is not. JS is
Node's fetch. TLS verifies the peer and its name on every platform. ClientTls.trusting(pem)
(JVM, Native) pins a private CA; Node reads NODE_EXTRA_CA_CERTS.
ZIO.attemptBlocking(java.net.ServerSocket(0)).flatMap { ss =>
val port = ss.getLocalPort
ss.close()
Client.get(s"http://127.0.0.1:$port/").either
}Left(Connect to 127.0.0.1:41699 failed: java.net.ConnectException: Connection refused (connect failed))Client.call(endpoint)(in) is the typed call. It fails with CallFailure[E]: Domain(e) for
one of the endpoint's own errors, Transport for a ClientError, Undecodable for a body or
response header the codecs reject. The reason is an OutputError: Body for the body, Header
naming the header and its ParamError. Unexpected is a status the endpoint never declared.
Nothing becomes a defect.
{
val ep = Endpoint.get("hello" / string("name")).out[String].outError[String](Status.NotFound)
val api =
Api("hello", "1").bind(ep)(name => if name == "ada" then ZIO.succeed(s"hi $name") else ZIO.fail("nobody"))
(Client.call(ep)("ada") <*> Client.call(ep)("bob").either).provideLayer(Client.inMemory(api.routes))
}(hi ada,Left(Domain(nobody)))Files and compression
A caller-given file is Files.fromPath (jailed under SafePath for directory roots).
It fails with a FileError (NotFound, IsDirectory, Unreadable), and toResponse is
the answer a client should see: 404 for the first two, 500 when the disk fails.
Compression is @@ Middleware.compress (gzip in core). Add heddle-brotli and pass
Brotli.compressor when you want br. Incoming Content-Encoding is
@@ Middleware.decompress(maxBytes = ...). Inflation stops at maxBytes: a small gzip bomb
gets 413 before it can fill memory, and corrupt input gets 400.
{
val bomb = Compressor.gzip.compress(zio.Chunk.fill(8 * 1024 * 1024)(0.toByte))
val routes = Routes(Method.POST / "in" -> Handler.text("reached")) @@ Middleware.decompress(maxBytes = 64.K)
routes(Request.post("/in", Body.fromBytes(bomb)).withHeader("Content-Encoding", "gzip"))
.map(res => (bomb.length, res.status))
}(8175,Status(413,Content Too Large))Brotli.encode(zio.Chunk.fromArray("hi".getBytes("UTF-8"))).nonEmptytrue{
val routes = Routes(Method.GET / "gone" -> Handler.fromFile("/no/such/file.txt").mapError(_.toResponse))
routes(Request.get("/gone")).merge.map(_.status)
}Status(404,Not Found)