ProtocolStack
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:
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:
Here is the flow of data through the ProtocolStack
:
- The incoming input
II
is transformed by the first layer of the protocol stack to produce the incoming outputIO
. - 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. - 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 inputOI
. - The outgoing input
OI
is passed to the last layer of the protocol stack to produce the outgoing outputOO
. - 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. - The outgoing output
OO
is returned as the final result of the protocol stack.
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:
import zio._
import zio.http._
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:
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:
val handler: Handler[Any, Response, Request, Response] = identity(uppercase)
The behavior of the handler
remains the same. Let's test it:
Unsafe.unsafe{ implicit unsafe =>
Runtime.default.unsafe.run(
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:
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:
val newHandler: Handler[Any, (Response, Int), Request, (Response, Int)] =
trimAndLength(uppercase)
Unsafe.unsafe { implicit unsafe =>
Runtime.default.unsafe.run(
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:
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:
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@1b842039),
// right = Incoming(handler = zio.http.Handler$FromFunction$$anon$16@2bb41ab5)
// )
Now, let's apply the uppercase
handler to the anotherTrimAndLength
stack:
Unsafe.unsafe { implicit unsafe =>
Runtime.default.unsafe.run(
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:
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:
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 typeOO
.
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:
import java.util.concurrent.TimeUnit
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:
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:
Unsafe.unsafe { implicit unsafe =>
Runtime.default.unsafe.run(
responseTime(handler).apply("Hello, World!").debug("Response along with its latency"),
)
}
// Response along with its latency: (HELLO, WORLD!,2228)
// res3: Exit[(String, Long), (String, Long)] = Success(
// value = ("HELLO, WORLD!", 2228L)
// )
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:
package example.middleware
import zio._
import zio.http._
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(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:
import zio._
import zio.http._
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.