Routing & HTTP
Controllers#
The Athena Framework is a MVC based framework, as such, the logic to handle a given route is defined within an ATH::Controller.
Athena Framework takes an annotation based approach to routing.
An annotation, such as ARTA::Get
is applied to an instance method of a controller class, which will be executed when that endpoint receives a request.
Creating a Route#
In Athena Framework, controllers are simply classes and route actions are simply methods. This means they can be documented/tested as you would any Crystal class/method. However see the testing section for how to best test a controller.
require "athena"
# Define a controller
class ExampleController < ATH::Controller
# Define an action to handle the related route
@[ARTA::Get("/")]
def index : String
"Hello World"
end
# The macro DSL can also be used
get "/" do
"Hello World"
end
end
# Run the server
ATH.run
# GET / # => Hello World
Routing is handled via the Athena::Routing component. It provides a flexible and robust foundation for handling determining which route should match a given request.
Tip
Check out the debug:router
command to view all of the routes the framework is aware of within your application.
Raw Response#
An ATH::Response can be used to fully customize the response; such as returning a specific status code, or adding some one-off headers.
require "athena"
require "mime"
class ExampleController < ATH::Controller
# A GET endpoint returning an `ATH::Response`.
# Can be used to return raw data, such as HTML or CSS etc, in a one-off manner.
@[ARTA::Get("/index")]
def index : ATH::Response
ATH::Response.new(
"<h1>Welcome to my website!</h1>",
headers: HTTP::Headers{"content-type" => MIME.from_extension(".html")}
)
end
end
ATH.run
# GET /index # => "<h1>Welcome to my website!</h1>"
A View event is emitted if the returned value is NOT an ATH::Response.
By default, non ATH::Response
s are JSON serialized.
However, this event can be listened on to customize how the value is serialized.
More on this in the Content Negotiation section.
Route Parameters#
Arguments are converted to their expected types if possible, otherwise an error response is automatically returned.
The values are provided directly as method arguments, thus preventing the need for env.params.url["name"]
and any boilerplate related to it.
Just like normal method arguments, default values can be defined.
The method's return type adds some type safety to ensure the expected value is being returned.
require "athena"
class ExampleController < ATH::Controller
@[ARTA::Get("/add/{value1}/{value2}")]
@[ATHA::QueryParam("negative")]
def add(value1 : Int32, value2 : Int32, negative : Bool = false) : Int32
sum = value1 + value2
negative ? -sum : sum
end
end
ATH.run
# GET /add/2/3 # => 5
# GET /add/5/5?negative=true # => -10
# GET /add/foo/12 # => {"code":400,"message":"Required parameter 'value1' with value 'foo' could not be converted into a valid 'Int32'"}
Tip
For more complex conversions, consider creating a Value Resolver to encapsulate the logic.
Query Params#
ATHA::QueryParam and ATHA::RequestParams are defined via annotations and map directly to the method's arguments.
require "athena"
class ExampleController < ATH::Controller
@[ARTA::Get("/")]
@[ATHA::QueryParam("page", requirements: /\d{2}/)]
def index(page : Int32) : Int32
page
end
end
ATH.run
# GET / # => {"code":422,"message":"Parameter 'page' of value '' violated a constraint: 'This value should not be null.'\n"}
# GET /?page=10 # => 10
# GET /?page=bar # => {"code":400,"message":"Required parameter 'page' with value 'bar' could not be converted into a valid 'Int32'."}
# GET /?page=5 # => {"code":422,"message":"Parameter 'page' of value '5' violated a constraint: 'Parameter 'page' value does not match requirements: (?-imsx:^(?-imsx:\\d{2})$)'\n"}
Raw Request#
Restricting an action argument to ATH::Request will provide the raw request object. This can be useful to access data directly off the request object, such as consuming the request's body. This approach is fine for simple or one-off endpoints.
Tip
Check out ATHR::RequestBody for a better way to handle this.
require "athena"
class ExampleController < ATH::Controller
@[ARTA::Post("/data")]
def data(request : ATH::Request) : String
raise ATH::Exception::BadRequest.new "Request body is empty." unless body = request.body
JSON.parse(body).as_h["name"].as_s
end
end
ATH.run
# POST /data body: {"id":1,"name":"Jim"} # => Jim
Streaming Response#
By default ATH::Response
content is written all at once to the response's IO
.
However in some cases the content may be too large to fit into memory. In this case an ATH::StreamedResponse may be used to stream the content back to the client.
require "athena"
require "mime"
class ExampleController < ATH::Controller
@[ARTA::Get(path: "/users")]
def users : ATH::Response
ATH::StreamedResponse.new headers: HTTP::Headers{"content-type" => "application/json; charset=UTF-8"} do |io|
User.all.to_json io
end
end
end
ATH.run
# GET /athena/users" # => [{"id":1,...},...]
File Response#
An ATH::BinaryFileResponse may be used to return static files/content. This response type handles caching, partial requests, and setting the relevant headers. The Athena Framework also supports downloading of dynamically generated content by using an ATH::Response with the content-disposition header. ATH::HeaderUtils.make_disposition can be used to easily build the header.
require "athena"
require "mime"
class ExampleController < ATH::Controller
@[ARTA::Get(path: "/data/export")]
def data_export : ATH::Response
content = # ...
ATH::Response.new(
content,
headers: HTTP::Headers{
"content-disposition" => ATH::HeaderUtils.make_disposition(:attachment, "data.csv"),
"content-type" => MIME.from_extension(".csv")
}
)
end
end
ATH.run
Static Files#
Static files can also be served from an Athena application. This can be achieved by combining an ATH::BinaryFileResponse with the request event; checking if the request's path represents a file/directory within the application's public directory and returning the file if so.
# Register a request event listener to handle returning static files.
@[ADI::Register]
struct StaticFileListener
# This could be parameter if the directory changes between environments.
private PUBLIC_DIR = Path.new("public").expand
# Run this listener with a very high priority so it is invoked before any application logic.
@[AEDA::AsEventListener(priority: 256)]
def on_request(event : ATH::Events::Request) : Nil
# Fallback if the request method isn't intended for files.
# Alternatively, a 405 could be thrown if the server is dedicated to serving files.
return unless event.request.method.in? "GET", "HEAD"
original_path = event.request.path
request_path = URI.decode original_path
# File path cannot contains '\0' (NUL).
if request_path.includes? '\0'
raise ATH::Exception::BadRequest.new "File path cannot contain NUL bytes."
end
request_path = Path.posix request_path
expanded_path = request_path.expand "/"
file_path = PUBLIC_DIR.join expanded_path.to_kind Path::Kind.native
is_dir = Dir.exists? file_path
is_dir_path = original_path.ends_with? '/'
event.response = if request_path != expanded_path || is_dir && !is_dir_path
redirect_path = expanded_path
if is_dir && !is_dir_path
redirect_path = expanded_path.join ""
end
# Request is a directory but acting as a file,
# redirect to the actual directory URL.
ATH::RedirectResponse.new redirect_path
elsif File.file? file_path
ATH::BinaryFileResponse.new file_path
else
# Nothing to do.
return
end
end
end
URL Generation#
A common use case, especially when rendering HTML
, is generating links to other routes based on a set of provided parameters.
When in the context of a request, the scheme and hostname of a ART::Generator::ReferenceType::ABSOLUTE_URL defaults to http
and localhost
respectively, if they could not be extracted from the request.
In Controllers#
The parent ATH::Controller type provides some helper methods for generating URLs within the context of a controller.
require "athena"
class ExampleController < ATH::Controller
# Define a route to redirect to, explicitly naming this route `add`.
# The default route name is controller + method down snake-cased; e.x. `example_controller_add`.
@[ARTA::Get("/add/{value1}/{value2}", name: "add")]
def add(value1 : Int32, value2 : Int32, negative : Bool = false) : Int32
sum = value1 + value2
negative ? -sum : sum
end
# Define a route that redirects to the `add` route with fixed parameters.
@[ARTA::Get("/")]
def redirect : ATH::RedirectResponse
# Generate a link to the other route.
url = self.generate_url "add", value1: 8, value2: 2
url # => /add/8/2
# Redirect to the user to the generated url.
self.redirect url
# Or could have used a method that does both
self.redirect_to_route "add", value1: 8, value2: 2
end
end
ATH.run
# GET / # => 10
Note
Passing arguments to #generate_url
that are not part of the route definition are included within the query string of the generated URL.
self.generate_url "blog", page: 2, category: "Crystal"
# The "blog" route only defines the "page" parameter; the generated URL is:
# /blog/2?category=Crystal
In Services#
A service can define a constructor parameter typed as ART::Generator::Interface in order to obtain the router
service:
@[ADI::Register]
class SomeService
def initialize(@url_generator : ART::Generator::Interface); end
def some_method : Nil
sign_up_page = @url_generator.generate "sign_up"
# ...
end
end
In Commands#
Generating URLs in commands works the same as in a service.
However, commands are not executed in an HTTP context.
Because of this, absolute URLs will always generate as http://localhost/
instead of your actual host name.
The solution to this is to configure the framework.router.default_uri configuration value. This'll ensure URLs generated within commands have the proper host.
ATH.configure({
framework: {
router: {
default_uri: "https://example.com/my/path",
},
},
})
WebSockets#
Currently due to Athena Framework's architecture, WebSockets are not directly supported. However the framework does allow prepending HTTP::Handler to the internal server. This could be used to leverage the standard library's HTTP::WebSocketHandler handler or a third party library such as https://github.com/cable-cr/cable.
require "athena"
# ...
ws_handler = HTTP::WebSocketHandler.new do |ws, ctx|
ws.on_ping { ws.pong ctx.request.path }
end
ATH.run prepend_handlers: [ws_handler]
Alternatively, the Athena::Mercure component may be used as a replacement of the more common websocket use cases.
Content Negotiation#
As mentioned earlier, controller action responses are JSON serialized if the controller action does NOT return an ATH::Response. The Negotiation component enhances the view layer of the Athena Framework by enabling content negotiation support; making it possible to write format agnostic controllers by placing a layer of abstraction between the controller and generation of the final response content. Or in other words, allow having the same controller action be rendered based on the request's Accept header and the format priority configuration.
Format Priority#
The content negotiation logic is disabled by default, but can be easily enabled via the related bundle configuration. Content negotiation configuration is represented by an array of rules used to describe allowed formats, their priorities, and how things should function if a unsupported format is requested.
For example, say we configured things like:
ATH.configure({
framework: {
format_listener: {
enabled: true,
rules: [
# Setting fallback_format to json means that instead of considering
# the next rule in case of a priority mismatch, json will be used.
{priorities: ["json", "xml"], host: /api\.example\.com/, fallback_format: "json"},
# Setting fallback_format to false means that instead of considering
# the next rule in case of a priority mismatch, a 406 will be returned.
{path: /^\/image/, priorities: ["jpeg", "gif"], fallback_format: false},
# Setting fallback_format to nil (or not including it) means that
# in case of a priority mismatch the next rule will be considered.
{path: /^\/admin/, priorities: ["xml", "html"]},
# Setting a priority to */* basically means any format will be matched.
{priorities: ["text/html", "*/*"], fallback_format: "html"},
],
},
},
})
Assuming an accept
header with the value text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8,application/json
: a request made to /foo
from the api.example.com
hostname; the request format would be json
. If the request was not made from that hostname; the request format would be html
. The rules can be as complex or as simple as needed depending on the use case of your application.
View Handler#
The ATH::View::ViewHandler is responsible for generating an ATH::Response in the format determined by the ATH::Listeners::Format, otherwise falling back on the request's format, defaulting to json
.
The view handler has a options that may also be configured via the ATH::Bundle::Schema::ViewHandler schema.
ATH.configure({
framework: {
view_handler: {
# The HTTP::Status to use if there is no response body, defaults to 204.
empty_content_status: :im_a_teapot,
# If `nil` values should be serialized, defaults to false.
serialize_nil: true
},
},
})
Views#
An ATH::View is intended to act as an in between returning raw data and an ATH::Response. In other words, it still invokes the view event, but allows customizing the response's status and headers. Convenience methods are defined in the base controller type to make creating views easier. E.g. ATH::Controller#view.
View Format Handlers#
By default the Athena Framework uses json
as the default response format.
However it is possible to extend the ATH::View::ViewHandler to support additional, and even custom, formats.
This is achieved by creating an ATH::View::FormatHandlerInterface instance that defines the logic needed to turn an ATH::View into an ATH::Response.
The implementation can be as simple/complex as needed for the given format.
Official handlers could be provided in the future for common formats such as html
, probably via an integration with some form of tempting engine utilizing custom annotations to specify the format.
Adding/Customizing Formats#
ATH::Request::FORMATS represents the formats supported by default. However this list is not exhaustive and may need altered application to application; such as registering new formats.
Example#
The following is a demonstration of how the various negotiation features can be used in conjunction. The example includes:
- Defining a custom ATH::View::ViewHandler for the
csv
format. - Enabling content negotiation, supporting
json
andcsv
formats, falling back tojson
. - An endpoint returning an ATH::View that sets a custom HTTP status.
require "athena"
require "csv"
# An interface to denote a type can provide its data in CSV format.
#
# An easier/more robust implementation can probably be thought of,
# however this is mainly for demonstration purposes.
module CSVRenderable
abstract def to_csv(builder : CSV::Builder) : Nil
end
# Define an example entity type.
record User, id : Int64, name : String, email : String do
include CSVRenderable
include JSON::Serializable
# Define the headers this type has.
def self.headers : Enumerable(String)
{
"id",
"name",
"email",
}
end
def to_csv(builder : CSV::Builder) : Nil
# Add the related values based on `self.`
builder.row @id, @name, @email
end
end
# Register our handler as a service.
@[ADI::Register]
class CSVFormatHandler
# Implement the interface.
include ATH::View::FormatHandlerInterface
# :inherit:
def call(view_handler : ATH::View::ViewHandlerInterface, view : ATH::ViewBase, request : ATH::Request, format : String) : ATH::Response
view_data = view.data
headers = if view_data.is_a? Enumerable
typeof(view_data.first).headers
else
view_data.class.headers
end
data = if view_data.is_a? Enumerable
view_data
else
{view_data}
end
# Assume each item has the same headers.
content = CSV.build do |csv|
csv.row headers
data.each do |r|
r.to_csv csv
end
end
# Return an ATH::Response with the rendered CSV content.
# Athena handles setting the proper content-type header based on the format.
# But could be overridden here if so desired.
ATH::Response.new content
end
# :inherit:
def format : String
"csv"
end
end
ATH.configure({
framework: {
format_listener: {
enabled: true,
rules: [
# Allow json and csv formats, falling back on json if an unsupported format is requested.
{priorities: ["json", "csv"], fallback_format: "json"}
]
},
}
})
class ExampleController < ATH::Controller
@[ARTA::Get("/users")]
def get_users : ATH::View(Array(User))
self.view([
User.new(1, "Jim", "[email protected]"),
User.new(2, "Bob", "[email protected]"),
User.new(3, "Sally", "[email protected]"),
], status: :im_a_teapot)
end
end
ATH.run