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 toATH::Spec::AbstractBrowser
- Add custom HTTP related expectation methods to
-
Add an
HTTP::Handler
compliant type to allow augmenting a stdlibHTTP::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
-
Collection constraint to allow testing any
-
Additional Spec features
- ASPEC::TestCase::TestWith that works similar to ASPEC::TestCase::DataProvider but without needing to create a dedicated method
- Add support for codegen for the ASPEC.assert_error and
ASPEC.assert_success
methods -
ASPEC::TestCase::Skip
annotation to allow skipping all examples within a test case instance
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:
- New Intro landing page
- Explanation of benefits of Athena and what it can be used for
- Introduction to the conventions used within the ecosystem
- Updated getting started docs (more examples + merged in adv usages)
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:
- The method name is no longer limited to
call
- 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
- The second
dispatcher : AED::EventDispatcherInterface
parameter is now totally optional, so it can be omitted if you do not need it - 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:
-
ATH::Commands::DebugEventDispatcher
- Display configured listeners for an application -
ATH::Commands::DebugRouter
- Display current routes for an application -
ATH::Commands::DebugRouterMatch
- Simulate a path match to see which route, if any, would handle it
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:
- 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.
- 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.
- 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 aTime
instance. The resolver is applied automatically if possible just by typing that parameter asTime
. - 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.
- 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.
- 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.