Athena Framework 0.18.0

Athena Framework 0.18.0

This Framework release focused on polishing existing features, and more tightly integrating other components into the framework. The ecosystem itself also benefited due to some components getting updates of their own.

Highlights include the following, but read on for more specific details/examples!

  • Revamped website

    • Better introduce the benefits of Athena
    • Better explain some of the conventions used within the ecosystem
  • Skeleton template repository

    • Provides a skeleton application to make it easier to get started with the framework
    • Includes suggested organizational structure, CI, and best practice READMEs throughout
  • New and improved Athena::EventDispatcher component

    • Leverage an annotation instead of class methods to allow only instantiating the listeners that have events dispatched
    • Better internal implementation to allow for more flexibility
  • Integration of the Athena::Console component

    • Framework users can now create CLI commands for administrative, or scheduled tasks (e.g. via cron)
    • Built-in framework level commands to aid in debugging routing and/or event listeners
    • Framework commands are automatically registered similar to event listeners if also registered as a service and are lazy so only the executed command(s) are instantiated
  • Deprecate the Param Converter concept in favor of value resolvers

    • Value resolvers fill the same use case as param converters but are more powerful
    • Removed need for the @[ATH::ParamConverter] annotation in some cases
    • For more complex cases, annotations applied to specific parameters can be used to enable a particular resolver, and/or to pass additional data to it (e.g. the string format to use when parsing a string into a Time instance)
  • Improve integration testing UX

    • Add custom HTTP related expectation methods to ATH::Spec::WebTestCase
    • Add #response and #request methods to ATH::Spec::AbstractBrowser
  • Add an HTTP::Handler compliant type to allow augmenting a stdlib HTTP::Server with routing

  • New Validation constraints

    • Collection constraint to allow testing any Enumerable({K, V}) without creating a dedicated class
    • Default email mode is now :html5
  • Additional Spec features


Revamped Website

Athena’s website has undergone some reorganization to both make it easier to navigate/know what specific pages are, but also better document the reasons why to choose Athena and some of the conventions used within the ecosystem:

Skeleton

In addition to the new website, a skeleton repository template was created with the goal of making it easier to get started with the framework. It includes the suggested organizational structure, CI, and best practice READMEs throughout. It’s fairly bare bones, just so there isn’t so much to delete when first getting started with it. However there are plans for another example repository that will demonstrate what a simple blog application would look like when using the Athena Framework.

EventDispatcher

The Athena Framework is an events based framework, meaning it emits various events during the life-cycle of a request. These events are listened upon to handle each request. All of this is orchestrated via the Athena::EventDispatcher component, which got a major update that not only makes it simpler to use, but also more powerful from a feature/QoL perspective.

Previously, event listeners were created by including a module and defining a class method describing the events and their priority. Multiple #call methods could then be overloaded to handle more than one event.

Before
@[ADI::Register]
class CustomListener
  include AED::EventListenerInterface

  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

Following along with the rest of the framework, event listeners are now registered via annotating a method versus using the class method. This has a few benefits:

  1. The method name is no longer limited to call
  2. Shorter, more concise code since you no longer have to duplicate the event type twice (one in the class method and one for the actual listener method) since the event for a particular method is inferred based on the parameter’s type restriction
  3. The second dispatcher : AED::EventDispatcherInterface parameter is now totally optional, so it can be omitted if you do not need it
  4. Since annotations are known at compile time, service based listeners used in conjunction with the Athena::DependencyInjection component, as it is within the framework; only listeners on events that are emitted will actually be instantiated. This can have a positive impact on performance if a lot of custom events are defined/used, but not for every request.
After
@[ADI::Register]
class CustomListener
  include AED::EventListenerInterface

  @[AEDA::AsEventListener]
  def on_response(event : ATH::Events::Response) : Nil
    event.response.headers["FOO"] = "BAR"
  end
end

Framework

This version of the Athena Framework primarily focused better integrating each component into the framework and consolidating features within the framework component itself.

Console Integration

One of the more exciting things to be included in this release is the integration of the console component. Previously the Athena Framework could only be used in an HTTP context. This integration allows for an alternate CLI based entrypoint into the framework, either in addition to the HTTP side, or on its own. This most commonly can be used for internal administrative, or scheduled tasks via cron or something similar.

What sets the Athena::Console component apart is that offers more than just argument parsing features. It is a fully fledged framework for creating CLI applications. However it is not intended to be used to create TUIs. The component includes the ability to ask questions, create reusable styles, render tables, and provides testing abstractions to ensure your commands are doing what they should be.

Similar to the newly refactored Athena::EventDispatcher component, console commands are registered via an annotation, and when also registered as services via the Athena::DependencyInjection component, they will be automatically registered with the additional benefit of only instantiating the command class(es) that are being executed. As an added bonus of using the DI component, services may be shared between controllers and commands. Just one of the benefits of following the SOLID Principles.

Example Usage
@[ACONA::AsCommand("app:create-user")]
@[ADI::Register]
class CreateUserCommand < ACON::Command
  protected def configure : Nil
    # ...
  end

  protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status
    # Implement all the business logic here.

    # Indicates the command executed successfully.
    Status::SUCCESS
  end
end

Built-In Commands

The framework component takes this a step further by baking in some commonly useful commands to aid with debugging your application. This includes:

Debug Event Dispatcher
$ ./bin/console debug:event-dispatcher
Registered Listeners Grouped by Event
=====================================

Athena::Framework::Events::Action event
---------------------------------------

 ------- -------------------------------------------------------- ----------
  Order   Callable                                                 Priority
 ------- -------------------------------------------------------- ----------
  #1      Athena::Framework::Listeners::ParamFetcher#on_action     5
 ------- -------------------------------------------------------- ----------

Athena::Framework::Events::Exception event
------------------------------------------

 ------- -------------------------------------------------- ----------
  Order   Callable                                           Priority
 ------- -------------------------------------------------- ----------
  #1      Athena::Framework::Listeners::Error#on_exception   -50
 ------- -------------------------------------------------- ----------

Athena::Framework::Events::Request event
----------------------------------------

 ------- -------------------------------------------------- ----------
  Order   Callable                                           Priority
 ------- -------------------------------------------------- ----------
  #1      Athena::Framework::Listeners::CORS#on_request      250
  #2      Athena::Framework::Listeners::Format#on_request    34
  #3      Athena::Framework::Listeners::Routing#on_request   32
 ------- -------------------------------------------------- ----------

...
Debug Router
$ ./bin/console debug:router
----------------  -------  -------  -----  --------------------------------------------
Name              Method   Scheme   Host   Path
----------------  -------  -------  -----  --------------------------------------------
homepage          ANY      ANY      ANY    /
contact           GET      ANY      ANY    /contact
contact_process   POST     ANY      ANY    /contact
article_show      ANY      ANY      ANY    /articles/{_locale}/{year}/{title}.{_format}
blog              ANY      ANY      ANY    /blog/{page}
blog_show         ANY      ANY      ANY    /blog/{slug}
----------------  -------  -------  -----  --------------------------------------------

Value Resolvers

As mentioned in the Athena 0.17.0 release announcement, value resolves were intended to replace the previous ParamConverter implementation. This release does just that, fully replacing them with value resolvers. Value resolvers are more powerful in a couple ways:

  1. The order in which value resolvers are applied can be controlled unlike param converters. This allows for creating a hierarchy that tries more specialized resolvers before less specific ones, which can also be to override/customize built-in resolvers if so desired.
  2. They provide full access to the underlying parameter, which can be used to automatically know if a given resolver should be invoked based on the name or type of the parameter for example.
  3. Due to the previous point, there is no need to add a dedicated @[ATHA::ParamConverter] annotation just because you want to consume a datetime string into a Time instance. The resolver is applied automatically if possible just by typing that parameter as Time.
  4. Thanks to https://github.com/crystal-lang/crystal/pull/12044, annotations may now be applied directly to specific parameters. This provides a much more robust way of handling resolver configuration that doesn’t involve mapping the information from an annotation on the method solely based on the name of the parameter.
  5. While some resolvers can be globally enabled based on the type/name of the parameter, that is not true for all. In cases where it is required to be more explicit, the presence of an annotation may be used to enable a particular resolver. Such as knowing if it’s value should be extracted from the request body.
  6. More flexibility and type safety. If using an annotation to enable the resolver, you can have it define multiple overloads to handle parameters of different types as well as limit what types a resolver supports at compile time.

Spec Improvements

Integration testing controller actions is a very beneficial way of validating their correctness. They not only test the logic within the action itself, but also its integration with the rest of the framework, such as value resolvers, event listeners, etc. However, when writing these tests, it may not be that obvious why a particular test is not passing as expected. This release introduces some DX changes to help alleviate that.

Expectation Methods

The newly introduced ATH::Spec::Expectations::HTTP module adds numerous helper expectation methods, such as asserting the response is successful, has a specific header/cookie (value), and/or if the request has an attribute with a specific value. All the while providing actually useful debugging output if that is not the case. For example if you were to use self.assert_response_is_successful, and the request was NOT successful, it’ll fail telling you that, but also will include the response headers and body as clues to help figure out why that was the case. Additionally if an exception occurred, its message/location will be included in the output.

Example Output
 Failed asserting that the response is successful:
 HTTP/1.1 500 Internal Server Error

 content-type: application/json; charset=UTF-8
 x-debug-exception-message: Oh%20noes
 x-debug-exception-class: Exception
 x-debug-exception-code: 500
 x-debug-exception-file: src/controllers/example_controller.cr:4:5
 cache-control: no-cache, private
 date: Mon, 20 Feb 2023 23:15:27 GMT
 Content-Length: 46

 {"code":500,"message":"Internal Server Error"}

 Caused By:
   Oh noes (Exception)
     from src/controllers/example_controller.cr:4:5

Routing

While not directly related to the new Framework version, the underlying routing component Athena::Routing got an exciting new feature of its own.

Routing Handler

The stdlib’s HTTP::Server provides a pretty solid HTTP server implementation, but it lacks one critical feature for anything more than the most basic use cases: routing. The routing component recently introduced the ART:::RoutingHandler type that can provide this capability in a low-overhead manner. It provides no additional features beyond routing, and as such can be a good option for simple use cases as opposed to being required to use a framework, just for its routing features.

Example Usage
handler = ART::RoutingHandler.new

# The `methods` property can be used to limit the route to a particular HTTP method.
handler.add "new_article", ART::Route.new("/article", methods: "post") do |ctx|
  pp ctx.request.body.try &.gets_to_end
end

# The match parameters from the route are passed to the callback as a `Hash(String, String?)`.
handler.add "article", ART::Route.new("/article/{id<\\d+>}", methods: "get") do |ctx, params|
  pp params # => {"_route" => "article", "id" => "10"}
end

# Call the `#compile` method when providing the handler to the handler array.
server = HTTP::Server.new([
  handler.compile,
])

address = server.bind_tcp 8080
puts "Listening on http://#{address}"
server.listen

Validator

Similar to the routing component, the Athena::Validator component got its own feature that may prove useful outside of the framework.

Collection Constraint

Previously the only way to validate a collection of data was to first create a type, include AVD::Validatable, define instance variables, apply the validation annotations to them, and then ultimately have some way to create an instance of this type based on the underlying data. This is fairly straightforward for JSON data via JSON::Serializable, or the serializer component, but less so for other forms of data, especially if it is already in a deserialized form.

The AVD::Constraints::Collection can be used for just this purpose. It accepts a Hash where the keys represent the keys in the collection, and the value is the constraint(s) used to validate the value at that key. Any Enumerable({K,V}), including URI::Params or any custom type. The constraint also supports presence and absence of fields, both on a global and per key basis. See the constraint API docs for more information.

Example Usage
constraint = AVD::Constraints::Collection.new({
  "email"           => AVD::Constraints::Email.new,
  "email_signature" => [
    AVD::Constraints::NotBlank.new,
    AVD::Constraints::Size.new(..100, max_message: "Your signature is too long"),
  ],
  "alternate_email" => AVD::Constraints::Optional.new([
    AVD::Constraints::Email.new,
  ] of AVD::Constraint),
})

data = {
  "email"           => "...",
  "email_signature" => "...",
}

validator.validate data, constraint

Spec

The Athena::Spec module is the unsung hero of the Athena ecosystem. It’s alternate Spec DSL powers the vast majority of the testing types provided by each component. But since it is NOT its own test runner, the stdlib’s Spec module may also be used on its own depending on the context. New versions of the spec module have been released that added some helpful new features to make testing your application even easier.

ASPEC::TestCase::TestWith

Instead of created a dedicated methods for use with ASPEC::TestCase::DataProvider, you can define a data set using the ASPEC::TestCase::TestWith annotation. The annotations accepts a variadic amount of Tuple positional/named arguments and will create a it case for each “set” of data.

Example Usage
struct TestWithTest < ASPEC::TestCase
  @[TestWith(
    two: {2, 4},
    three: {3, 9},
    four: {4, 16},
    five: {5, 25},
  )]
  def test_squares(value : Int32, expected : Int32) : Nil
    (value ** 2).should eq expected
  end

  @[TestWith(
    {2, 8},
    {3, 27},
    {4, 64},
    {5, 125},
  )]
  def test_cubes(value : Int32, expected : Int32) : Nil
    (value ** 3).should eq expected
  end
end

TestWithTest.run # =>
# TestWithTest
#   squares two
#   squares three
#   squares four
#   squares five
#   cubes 0
#   cubes 1
#   cubes 2
#   cubes 3

Codegen Support

The spec component’s assert_error and assert_success methods can be a useful tool in testing custom compile time errors. However in some cases a runtime assertion needs to be tested in isolation. These methods now support a codegen: true named argument that can be used to handle this. Without it, the code is only validated to compile, excluding any runtime exceptions.

9 Likes