DEV Community

Blacksmoke16
Blacksmoke16

Posted on

The Rebirth of Athena

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

  1. Define a custom view listener that runs before the default one (future listeners will not run once #response= is called)
  2. Subclass ART::Response to encapsulate your logic
  3. 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
Enter fullscreen mode Exit fullscreen mode

Macro DSL

One of the draws of Sinatra, and by extension, Kemal is the super simple syntax.

get "/index"
  "INDEX"
end
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)