Middleware: Crystal Edition
A as a Web software developer (mostly back-end or full-stack), often we have to intercept requests as they come in or intercept responses before they finally go out (to the client), and do some level of work during said interception.
On a request, you might have to check for HTTP headers as you validate the request, or add HTTP headers as you prepare the request for the next middleman in the processing of the request.
On a response, you might add HTTP headers to help the client process it or optimize the response, say to minimize impact on the network.
This is all to say, that as a Web software developer, you will find yourself writing middleware for your Web app regularly. With that in mind, let’s very briefly explore the different ways middleware is written in some of the most popular Web frameworks in the Crystal programming language ecosystem.
HTTP via Standard Libray API
At the most basic level, to build a HTTP server in Crystal, you can use the language’s standard library API via the HTTP module:
1
2
3
4
5
6
7
8
9
10
require "http/server"
server = HTTP::Server.new do |context|
context.response.content_type = "text/plain"
context.response.print "Hello world!"
end
address = server.bind_tcp 8080
puts "Listening on http://#{address}"
server.listen
With a HTTP server, to build any middleware, you will have to add a custom Handler:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# http_custom_handler.cr
require "http/server/handler"
class CustomHandler
include HTTP::Handler
def call(context)
# log to stdout
puts "Doing some stuff"
# call the next handler
call_next(context)
end
end
Together, it looks like so:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
require "http/server"
require "http/server/handler"
class CustomHandler
include HTTP::Handler
def call(context)
# log to stdout
puts "Doing some stuff"
# call the next handler
call_next(context)
end
end
server = HTTP::Server.new [
CustomHandler.new
], do |context|
context.response.content_type = "text/plain"
context.response.print "Hello world!"
end
address = server.bind_tcp 8080
puts "Listening on http://#{address}"
server.listen
and when you run this server code, it yields the following outputs of the client <-> server interactions:
Server output:
1
2
3
$ crystal run http_custom_handler.cr ✔ 4s
Listening on http://127.0.0.1:8080
Doing some stuff
Client output:
1
2
$ curl http://localhost:8080/
Hello world!%
Athena
A popular Web framework in the Crystal ecosystem is Athena. In Athena, to create middleware, you will want to use the Event Dispatcher by listening for HTTP events (requests, responses, etc.) like so:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
require "athena"
@[ADI::Register]
class CustomListener
include AED::EventListenerInterface
# Specify that we want to listen on the `Response` event.
# The value of the hash represents this listener's priority;
# the higher the value the sooner it gets executed.
def self.subscribed_events : AED::SubscribedEvents
AED::SubscribedEvents{ATH::Events::Response => 25}
end
def call(event : ATH::Events::Response,
dispatcher : AED::EventDispatcherInterface) : Nil
event.response.headers["FOO"] = "BAR"
end
end
class ExampleController < ATH::Controller
get "/" do
"Hello World"
end
end
ATH.run
# GET / # => Hello World (with `FOO => BAR` header)
When comparing the use of Athena to build middleware to that of using the standard library’s HTTP module, notice the use of Crystal annotations in Athena (to register event listeners) as opposed to not so in the standard library HTTP module.
Kemal
What Sinatra is to Ruby, Kemal is to Crystal.
In Kemal, to build middleware, you (also) use Handlers:
1
2
3
4
5
6
7
8
class CustomHandler < Kemal::Handler
def call(context)
puts "Doing some custom stuff here"
call_next context
end
end
add_handler CustomHandler.new
Notice how much this is like when using the Crystal standard library’s HTTP module.
Amber
What Ruby on Rails is to Ruby, Amber is to Crystal.
In Amber, to build middleware, you use Pipelines:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# amber_app_root/config/routes.cr
class CustomHandler
include HTTP::Handler
def call(context)
context.response.headers.add("X-Custom-Header", "Some Value")
call_next(context)
end
end
Amber::Server.configure do |app|
pipeline :web do
# Plug is the method to use connect a pipe (middleware)
# A plug accepts an instance of HTTP::Handler
# plug Amber::Pipe::Params.new
plug Amber::Pipe::Logger.new
plug Amber::Pipe::Flash.new
plug Amber::Pipe::Session.new
plug Amber::Pipe::CSRF.new
# Plug in a custom handler (a.k.a pipe)
plug CustomHandler.new
end
# All static content will run these transformations
pipeline :static do
plug HTTP::StaticFileHandler.new("./public")
plug HTTP::CompressHandler.new
end
routes :web do
get "/", HomeController, :index
end
routes :static do
get "/*", Amber::Controller::Static, :index
end
end
In Amber, just like in Kemal, you need to use the Handler class from the HTTP module to create your custom middleware (alternaltively, you can use the base class from the Amber pipe module, like the PoweredByAmber pipe does).
In Conclusion
With the exception of Athena (where you need to use Crystal annotations to listen for events), the most popular Web frameworks in the Crystal language ecosystem use the very straight forward Handler class from the HTTP module to intercept HTTP requests and responses. Simple enough for me (to build middleware with).
Thanks for reading.