module Athena::Routing
Overview
Athena is a set of independent, reusable components with the goal of providing a set of high quality, flexible, and robust framework building blocks. These components could be used on their own, or outside of the Athena ecosystem, to prevent every framework/project from needing to "reinvent the wheel."
The Athena::Routing
component is the result of combining these components into a single robust, flexible, and self-contained framework.
Getting Started
Athena does not have any other dependencies outside of Crystal
/Shards
.
It is designed in such a way to be non-intrusive, and not require a strict organizational convention in regards to how a project is setup;
this allows it to use a minimal amount of setup boilerplate while not preventing it for more complex projects.
Installation
Add the dependency to your shard.yml
:
dependencies:
athena:
github: athena-framework/athena
version: ~> 0.10.0
Run shards install
. This will install Athena and its required dependencies.
Usage
Athena has a goal of being easy to start using for simple use cases, while still allowing flexibility/customizability for larger more complex use cases.
Routing
Athena is a MVC based framework, as such, the logic to handle a given route is defined in an ART::Controller
class.
require "athena"
# Define a controller
class ExampleController < ART::Controller
# Define an action to handle the related route
@[ART::Get("/")]
def index : String
"Hello World"
end
# The macro DSL can also be used
get "/" do
"Hello World"
end
end
# Run the server
ART.run
# GET / # => Hello World
Annotations applied to the methods are used to define the HTTP method this method handles, such as ART::Get
or ART::Post
. A macro DSL also exists to make them a bit less verbose;
ART::Controller.get
or ART::Controller.post
. The ART::Route
annotation can also be used to define custom HTTP
methods.
Controllers are simply classes and routes are simply methods. Controllers and actions can be documented/tested as you would any Crystal class/method.
Route Parameters
Parameters, such as path/query parameters, are also defined via annotations and map directly to the method's arguments.
require "athena"
class ExampleController < ART::Controller
@[ART::QueryParam("negative")]
@[ART::Get("/add/:value1/:value2")]
def add(value1 : Int32, value2 : Int32, negative : Bool = false) : Int32
sum = value1 + value2
negative ? -sum : sum
end
end
ART.run
# GET /add/2/3 # => 5
# GET /add/5/5?negative=true # => -10
# GET /add/foo/12 # => {"code":422,"message":"Required parameter 'value1' with value 'foo' could not be converted into a valid 'Int32'"}
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 methods arguments, default values can be defined. The method's return type adds some type safety to ensure the expected value is being returned.
Restricting an action argument to HTTP::Request
will provide the raw request object, which can be used to retrieve the request data.
This approach is fine for simple or one-off endpoints, however for more complex/common request data processing, it is suggested to create
a Param Converter.
require "athena"
class ExampleController < ART::Controller
@[ART::Post("/data")]
def data(request : HTTP::Request) : String?
request.body.try &.gets_to_end
end
end
ART.run
# POST /data body: "foo--bar" # => "foo--bar"
An ART::Response
can also be used in order to fully customize the response, such as returning a specific status code, adding some one-off headers, or saving memory by directly
writing the response value to the Response IO.
require "athena"
require "mime"
class ExampleController < ART::Controller
# A GET endpoint returning an `ART::Response`.
@[ART::Get(path: "/css")]
def css : ART::Response
ART::Response.new ".some_class { color: blue; }", headers: HTTP::Headers{"content-type" => MIME.from_extension(".css")}
end
end
ART.run
# GET /css # => ".some_class { color: blue; }"
An ART::Events::View
is emitted if the returned value is NOT an ART::Response
. By default, non ART::Response
s are JSON serialized.
However, this event can be listened on to customize how the value is serialized.
Error Handling
Exception handling in Athena is similar to exception handling in any Crystal program, with the addition of a new unique exception type, ART::Exceptions::HTTPException
.
Custom HTTP
errors can also be defined by inheriting from ART::Exceptions::HTTPException
or a child type.
A use case for this could be allowing additional data/context to be included within the exception.
Non ART::Exceptions::HTTPException
exceptions are represented as a 500 Internal Server Error
.
When an exception is raised, Athena emits the ART::Events::Exception
event to allow an opportunity for it to be handled.
By default these exceptions will return a JSON
serialized version of the exception, via ART::ErrorRenderer
, that includes the message and code; with the proper response status set.
If the exception goes unhandled, i.e. no listener sets an ART::Response
on the event, then the request is finished and the exception is reraised.
require "athena"
class ExampleController < ART::Controller
get "divide/:num1/:num2", num1 : Int32, num2 : Int32, return_type: Int32 do
num1 // num2
end
get "divide_rescued/:num1/:num2", num1 : Int32, num2 : Int32, return_type: Int32 do
num1 // num2
# Rescue a non `ART::Exceptions::HTTPException`
rescue ex : DivisionByZeroError
# in order to raise an `ART::Exceptions::HTTPException` to provide a better error message to the client.
raise ART::Exceptions::BadRequest.new "Invalid num2: Cannot divide by zero"
end
end
ART.run
# GET /divide/10/0 # => {"code":500,"message":"Internal Server Error"}
# GET /divide_rescued/10/0 # => {"code":400,"message":"Invalid num2: Cannot divide by zero"}
Advanced Usage
Athena also ships with some more advanced features to provide more flexibility/control for an application. These features may not be required for a simple application; however as the application grows they may become more useful.
Param Converters
ART::ParamConverterInterface
s allow complex types to be supplied to an action via its arguments.
An example of this could be extracting the id from /users/10
, doing a DB query to lookup the user with the PK of 10
, then providing the full user object to the action.
Param converters abstract any custom parameter handling that would otherwise have to be done in each action.
User defined annotations registered via Athena::Config.configuration_annotation
may also be used to allow for more advanced logic for use within your param converters.
See ART::Events::RequestAware
for more information.
require "athena"
@[ADI::Register]
struct MultiplyConverter < ART::ParamConverterInterface
# :inherit:
def apply(request : HTTP::Request, configuration : Configuration) : Nil
arg_name = configuration.name
return unless request.attributes.has? arg_name
value = request.attributes.get arg_name, Int32
request.attributes.set arg_name, value * 2, Int32
end
end
class ParamConverterController < ART::Controller
@[ART::Get(path: "/multiply/:num")]
@[ART::ParamConverter("num", converter: MultiplyConverter)]
def multiply(num : Int32) : Int32
num
end
end
ART.run
# GET / multiply/3 # => 6
Middleware
Athena is an event based framework; meaning it emits ART::Events
that are acted upon internally to handle the request.
These same events can also be listened on by custom listeners, via AED::EventListenerInterface
, in order to tap into the life-cycle of the request.
An example use case of this could be: adding common headers, cookies, compressing the response, authentication, or even returning a response early like ART::Listeners::CORS
.
User defined annotations registered via Athena::Config.configuration_annotation
may also be used to allow for more advanced logic for use within your middleware.
An example of this could be a @[Paginated]
or @[RateLimited]
annotation. See ART::Events::RequestAware
for more information.
require "athena"
@[ADI::Register]
struct 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{
ART::Events::Response => 25,
}
end
def call(event : ART::Events::Response, dispatcher : AED::EventDispatcherInterface) : Nil
event.response.headers["FOO"] = "BAR"
end
end
class ExampleController < ART::Controller
get "/" do
"Hello World"
end
end
ART.run
# GET / # => Hello World (with `FOO => BAR` header)
Dependency Injection
Athena utilizes Athena::DependencyInjection
to provide a service container layer.
DI allows controllers/other services to be decoupled from specific implementations.
This makes testing easier as test implementations of the dependencies can be used.
In Athena, most everything is a service that belongs to the container, which is unique to the current request. The major benefit of this is it allows various types to be shared amongst the services.
For example, allowing param converters, controllers, etc. to have access to the current request via the ART::RequestStore
service.
Another example would be defining a service to store a UUID
to represent the current request, then using this service to include the UUID in the response headers.
require "athena"
require "uuid"
@[ADI::Register]
struct RequestIDStore
HEADER_NAME = "X-Request-ID"
# Inject `ART::RequestStore` in order to have access to the current request's headers.
def initialize(@request_store : ART::RequestStore); end
property request_id : String? = nil do
# Check the request store for a request.
request = @request_store.request?
# If there is a request and it has the Header,
if request && request.headers.has_key? HEADER_NAME
# use that ID.
request.headers[HEADER_NAME]
else
# otherwise generate a new one.
UUID.random.to_s
end
end
end
@[ADI::Register]
struct RequestIDListener
include AED::EventListenerInterface
def self.subscribed_events : AED::SubscribedEvents
AED::SubscribedEvents{
ART::Events::Response => 0,
}
end
def initialize(@request_id_store : RequestIDStore); end
def call(event : ART::Events::Response, dispatcher : AED::EventDispatcherInterface) : Nil
# Set the request ID as a response header
event.response.headers[RequestIDStore::HEADER_NAME] = @request_id_store.request_id
end
end
class ExampleController < ART::Controller
get "/" do
""
end
end
ART.run
# GET / # => (`X-Request-ID => 07bda224-fb1d-4b82-b26c-19d46305c7bc` header)
The main benefit of having RequestIDStore
and not doing event.response.headers[RequestIDStore::HEADER_NAME] = UUID.random.to_s
directly is that the value could be used in other places.
Say for example you have a route that enqueues messages to be processed asynchronously. The RequestIDStore
could be inject into that controller/service in order to include the same UUID
within the message in order to expand tracing to async contexts. Without DI, like in other frameworks, there would not be an easy to way to share the same instance of an object between
different types. It also wouldn't be easy to have access to data outside the request context.
DI is also what "wires" everything together. For example, say there is an external shard that defines a listener. All that would be required to use that listener is install and require the shard,
DI takes care of the rest. This is much easier and more flexible than needing to update code to add a new HTTP::Handler
instance to an array.
Extensions
Athena comes bundled with some additional potentially useful components (shards). Due to the nature of Crystal's build process, any types/methods that are not used, are not included in the resulting binary. Thus if your project does not use any of these extensions, the resulting binary will be unchanged. However by having them included by default, they are available and ready to go when/if the need arises. It's also worth noting that these extra components are not Athena specific and can be used within any project/library outside of the Athena ecosystem.
These extensions register additional component specific types as services with the service container.
This allows them to be injected via DI into your Athena::Routing
related types, such as controllers, param converters, and/or event listeners.
Serialization
The Athena::Serializer
component adds enhanced (de)serialization features.
See the API documentation for more detailed information, or this forum post for a quick overview.
Some highlights:
ASRA::Name
- Supporting different keys when deserializing versus serializingASRA::VirtualProperty
- Allow a method to appear as a property upon serializationASRA::IgnoreOnSerialize
- Allow a property to be set on deserialization, but should not be serialized (or vice versa)ASRA::Expose
- Allows for more granular control over which properties should be (de)serializedASR::ExclusionStrategies
- Allows defining runtime logic to determine if a given property should be (de)serializedASR::ObjectConstructorInterface
- Determine how a new object is constructed during deserialization, e.x. sourcing an object from the DB
Dependency Injection
This extension registers the following types as services:
Validation
The Athena::Validator
component adds a robust/flexible validation framework.
See the API documentation for more detailed information, or this forum post for a quick overview.
Dependency Injection
This extension registers the following types as services:
Custom Constraints
In addition to the general information for defining Custom Constraints,
the validator component defines a specific type for defining service based constraint validators: AVD::ServiceConstraintValidator
.
This type should be inherited from instead of AVD::ConstraintValidator
IF the validator for your custom constraint needs to be a service, E.x.
class Athena::Validator::Constraints::CustomConstraint < AVD::Constraint
# ...
@[ADI::Register]
struct Validator < AVD::ServiceConstraintValidator
def initialize(...); end
# :inherit:
def validate(value : _, constraint : AVD::Constraints::CustomConstraint) : Nil
# ...
end
end
end
Defined in:
annotations.crathena.cr
logging.cr
Constant Summary
-
LOGGER =
Log.for("athena.routing")
Class Method Summary
-
.run(port : Int32 = 3000, host : String = "0.0.0.0", reuse_port : Bool = false) : Nil
Runs an
HTTP::Server
listening on the given port and host.
Class Method Detail
Runs an HTTP::Server
listening on the given port and host.
require "athena"
class ExampleController < ART::Controller
@[ART::Get("/")]
def root : String
"At the index"
end
end
ART.run
See ART::Controller
for more information on defining controllers/route actions.