Rebirth
The past year has been quite the journey for Athena. It felt like not too long ago when I released version 0.1.0
, however now, after a lot of thinking, multiple iterations, and 7 minor versions later, I'm proud to announce the release of 0.8.0
, or what I like to call Rebirth
. This release brings about changes to nearly every part of Athena, both user facing and internally, as well as changes to the vision and organizational future of the Athena Framework itself.
Since its been a while since my last post, I thought I would take this opportunity to highlight some of the changes introduced in this latest version as well as give an update on the roadmap/vision for the framework as a whole. This blog post isn't intended to be a tutorial on how to use these features, I will however provide plenty of links out to examples or documentation either in the Blog Demo App/Blog Post or the API docs. Also, feel free to join me in the Athena Gitter channel.
Organizational
As part of this latest release, I took the time to make some organizational changes; this resulted in the creation of the athena-framework Github organization, which will be the home to all of the frameworks components. Related to this, I moved some of the components that were included in athena
core into the organization; namely dependency-injection and config. The main benefit of this is it allows other shards, outside of the Athena Framework to use each component independently from one another. The longer term goal is creating a common set of useful components that can be used by others, versus everyone creating a slightly different version of the same thing.
0.8.0
also removed the direct dependencies on crylog and CrSerializer. These, along with assert are going to be reworked to be more DI friendly, as well as work out some issues with their implementations I found along the way. They will then be moved into the athena-framework
organization. The plan for how these will be reintegrated into Athena
will be discussed in the DependencyInjection section.
Athena makes use of namespaces to group common types together, both for organizational and documentation purposes. This practice however can lead to really long names, such as Athena::Routing::Exceptions::NotFoundException.new ""
as you might have used once or twice. Unfortunately without an import system, like ES6 for example, there isn't really a way around this. In order to help alleviate this issue in the mean time, I created various three letter acronyms that point to each component's namespace. For example, ART for Athena::Routing
, or AED for Athena::EventDispatcher
. Using these aliases in 0.8.0
the previous example would now be ART::Exceptions::NotFound.new ""
.
Event Dispatcher
The most impactful change was the implementation of a Mediator and Observer pattern event library to handle the core logic of handling a request, as well as a replacement for HTTP::Handler
style middleware. Athena internally now operates by emitting various events that can be listened on in order to handle a request. Athena defines various listeners itself as well; including for routing, CORS, exceptions, and a view layer, which I will get to soon. Each event contains information related to the event, for example the Request
event contains the current request, while the Exception
event contains the current request and the exception that was raised.
The main advantage of this approach is it is much easier to add middleware to the framework since an array of HTTP::Handler
do not have to be supplied up front. The listener approach is also much more flexible since the order of execution can be more easily controlled, versus being based on the order of the handler array. Listeners could be defined in external shards and just by requiring it, the listener(s) would automatically be registered.
The other big advantage is listeners support DI, so other dependencies can be injected into the listener as needed. An example of this could be a security listener on the Request
event in order to authenticate a request; lookup the corresponding user, and set it within a service that exposes that user to the rest of the application. Another example could be listening on exceptions in order to log information about them for analytics or monitoring.
@[ADI::Register("@logger", tags: ["athena.event_dispatcher.listener"])]
# Define a listener to handle authenticating requests.
# Also register it as a service and give it the proper tag for it to be automatically registered.
struct MonitoringListener
# Define the interface to implement the required methods
include AED::EventListenerInterface
# Define this type as a service for DI to pick up.
include ADI::Service
# Specify that we want to listen on the `Exception` event.
# The value of the has represents this listener's priority;
# the higher the value the sooner it gets executed.
def self.subscribed_events : AED::SubscribedEvents
AED::SubscribedEvents{
ART::Events::Exception => 0,
}
end
# Define our initializer for DI to inject a logger instance.
def initialize(@logger : LoggerInterface); end
# Define a `#call` method scoped to the `Exception` event.
def call(event : ART::Events::Exception, _dispatcher : AED::EventDispatcherInterface) : Nil
# Log the exception message
@logger.error event.exception.message
# Do whatever else you want to do with the event, including emitting other events
end
end
Custom events can also be defined that can be emitted from user code, either from another listener, controller action, etc. The EventDispatcher
component can also be used independently outside of Athena.
Dependency Injection (DI)
The change to a listener based approach originated due to another flaw in the HTTP::Handler
approach; they do not play nicely with DI. The main issue is that they are instantiated once when you create the HTTP::Server
, outside of the request life-cycle, thus the service container is not available to inject dependencies, nor can the middleware exist as a service itself.
This fact, along with how common middleware is, made me want to rethink the overall design of Athena to be more DI friendly. As mentioned in the Interfaces & DI section in the vision issue, the ultimate goal is to make Athena solely depend on interfaces versus concrete types. This not only allows for a better DI implementation, but also make custom implementations, and testing easier.
The plan for the serializer and logger components is that they are optional; but if installed and required, have an ext
, or plugin
, or something, file that would better integrate it into the rest of the framework. An example of this could be defining some types from that component as services, or defining a basic implementation of an interface for use within Athena. For example:
# ext/logger.cr
@[ADI::Register(name: "logger")]
# Define a basic implementation of a logger service for services to inject
struct Logger
# Be sure it adheres to the interface. This also would allow
# external/third-party code to define their own implementation
# of the logger service to use
include LoggerInterface
...
end
# some_controller.cr
require "athena/ext/logger"
class SomeController < ART::Controller
include ADI::Injectable
# The logger could be injected into anything logging is needed.
# Each request would have its own logger instance
def initialize(@logger : LoggerInterface); end
@[ART::Get("some/path")]
def some_path : String
@logger.info "Some message"
...
end
end
Internally, Athena would utilize optional services to manage this. I.e. by default no logging, but if a logger
service is registered and available, use that; such as for debug information on each request, or logging exceptions etc.
ErrorRendererInterface
The ErrorRendererInterface
is a perfect example of how DI fits into the framework. The default error renderer will JSON serialize the exception and return that string to the client. However, this behavior is customizable by redefining the error_renderer
service; if for example you wish to return an HTML error page or something.
View Layer
In previous versions of Athena, the response format was not very customizable. The implementation was tied to some type that defines a render
class method on it. Once again this does not fit into the DI oriented framework I so desire, so I had to rethink the implementation to better handle that as well as improve the overall flexibility of the framework.
The solution to this is two fold, the ART::Response type and the view event.
ART::Response
The concept behind the ART::Response
type is that it represents a "pending" response to the client. It can be mutated, body rewritten etc, all without affecting the actual HTTP::Server::Response
. The idea behind it is that a controller action can either return an ART::Response
or not; the behavior of the framework changes slightly if the return value is an ART::Response
.
If a controller action returns an ART::Response
, or a subclass of it like ART::RedirectResponse, then that object is used directly as the response to the client; body, status, and headers are simply copied over to the actual HTTP::Server::Response
. This allows an action to return arbitrary data easily to fulfill simple use cases.
class TestController < ART::Controller
@[ART::Get("/css")]
def css : ART::Response
# A controller action returning CSS.
ART::Response.new ".some_class { color: blue; }", headers: HTTP::Headers{"content-type" => MIME.from_extension(".css")}
end
end
Another thing that came from this is the render macro; it provides a simple way to render ECR
templates. The variables used in the template can come directly from the action's arguments, such as the example in the API docs. This feature combined with ParamConverterInterface can make for a very easy way to render a user's profile for example:
@[ART::Get("/user/:id/profile")]
@[ART::ParamConverter("user", converter: DBConverter(User))]
def user_profile(user : User) : ART::Response
# Render the user profile for the user with the provided ID.
# The template has access to the full User object.
render "user_profile.ecr"
end
ART::Events::View
The second part of the view layer is the view event, and the corresponding default listener. When a controller action's return value is NOT an ART::Response
, the view event is emitted. The job of the listeners listening on the event is to convert the resulting value into an ART::Response
. The default listener does this by JSON serializing the value, by default using the standard library's #to_json
method, or in the future, optionally by the serializer
component if it is installed
In the future, a format negotiation algorithm will be implemented to call the correct renderer based on the request's Accept
headers. For now, if you wish to define a global custom format for your routes, you have a few options.
- Define a custom
view
listener that runs before the default one (future listeners will not run once #response= is called) - Subclass
ART::Response
to encapsulate your logic - Define a helper method/macro on
ART::Controller
to encapsulate your logic
Overall these changes make Athena a whole lot more flexible, making it viable to render HTML, or anything else that is required.
Routing
While not much changed in the routing aspect of Athena, I do want to point out some of the minor changes/additions that did occur.
QueryParams
In previous versions, query parameters were included directly within the HTTP method annotation as a NamedTuple
. In 0.8.0
, QueryParams are now defined by their own dedicated annotation. They still support constraints, and param converters.
@[ART::Get("/example")]
@[ART::QueryParam("value")] # Is typed as a string due to `name : String`
def get_user(name : String) : Nil
end
Macro DSL
One of the draws of Sinatra, and by extension, Kemal is the super simple syntax.
get "/index"
"INDEX"
end
One of the downsides of Athena that I've heard is its verbosity. To help with this, I created a similar macro DSL that simply abstracts the creation of a method and addition of the annotation.
class ExampleController < ART::Controller
# Super simple right?
get "index" do
"INDEX"
end
# It also works with arguments, and other annotations like ParamConverters/QueryParams
@[ART::QueryParam("value3")]
get "values/:value1/:value2", value1 : Int32, value2 : Float64, value3 : Int8 do
"Value1: #{value1} - Value2: #{value2} - Value3: #{value3}"
end
end
It can still get pretty verbose if you have many path arguments, with a non String
return type, and some route constraints, but this and the three letter acronym aliases will help reduce the learning curve, and make life a little bit easier.
Access Raw HTTP::Request
Before there was not really a way to access the current request object outside of the RequestStore, and related, not really an easy way to access the raw response body of said request. In addition, the action argument's name for POST
requests had to be body
. This was far from ideal and has been improved greatly in 0.8.0
. The raw HTTP::Request
and by extension, its body IO
, can now be accessed by simply typing an action argument to HTTP::Request
. Athena will see that and provide the raw object to that action.
@[ART::Post(path: "/foo")]
# The name of the argument can be anything as long as its type is `HTTP::Request`.
def post_body(request : HTTP::Request) : String
request.body.try &.gets_to_end
end
The Future
The overall roadmap for Athena is outlined in the vision issue. For the short term I will continue with fixing any issues, and improving the documentation as needed. I will also continue working on reworking and moving the remaining components into the Github organization.
The medium to long term includes the creation of additional components, such as resurrecting the CLI
component, as well as introducing a more structured framework to handle authentication and access control of a given route.
As usual, any issues/comments/questions, feel free to drop a comment on this article, or come join me in the Athena Gitter channel.
Top comments (0)