Athena 0.13.0
This release focused on the introduction of a new and improved ART::Config component, improvements to file handling, release of a new component, some new DI features, and the full roll-out of the new external documentation.
Re-imagined Configuration
Configuration in Athena previously was tightly coupled with YAML
files. This not only made it harder to work with, but also made things not as robust/flexible as I’d like. A large part of this release consisted of refactoring what “configuration” means in an Athena application, and “how” it should be used.
In the end, I decided upon the pattern of separating “configuration” into two distinct concepts: “configuration” and “parameters”. The configuration side of things is essentially unchanged; representing customizable values that determine how an Athena component/feature works. An example of this is how ART::Config::CORS can be used to control ART::Listeners::CORS. Parameters are a new concept that closely relate to configuration, but with some key differences. A parameter represents a reusable configuration value. Following along with our CORS
example, a parameter could represent the application’s app URL
that should be used as an allow_origin
value. An example of configuring the CORS
listener now looks like this, as opposed to requiring a YAML
file.
# Define an `AppParams` to hold parameters specific to our application.
# Having a dedicated namespace prevents conflicts with built-in or third party parameters.
struct AppParams
# Define a getter for our parameter, fetching the value of it from `ENV`.
getter app_url : String = ENV["APP_URL"]
# Define another parameter to represent if some_feature should be enabled.
getter some_feature_enable : Bool = Athena.environment != "development"
end
# Add our `AppParams` type into the base parameters types.
class ACF::Parameters
getter app : AppParams = AppParams.new
end
# Override the configuration type's `.configure` method to define our custom values.
def ART::Config::CORS.configure
new(
allow_credentials: true,
allow_origin: [ACF.parameters.app.app_url], # Use our `app_url` parameter
expose_headers: %w(X-Transaction-ID X-Some-Custom-Header),
)
end
Some of you may be wondering “why do I need all of this? Can’t I just hardcode the app URL in the .configure
method or use ENV
directly?” And you would be correct, there is nothing preventing you from doing just that. It’s important to note that parameters are not intended for values that change machine to machine, or that rarely change. These use cases would be better suited to environmental variables and constants. Parameters are primarily intended to be used for values that control that application’s behavior. Parameters can be thought of an extension of environmental variables, and a place to store application specific configuration that is the same machine to machine.
Using parameters does provide a few notable benefits:
- Is fully compile time type safe, both in regards to the structure of the parameters, and each value
- If the type of
app_url
was mistakenly set toInt32
or if the parameter’s name was typo’d, e.g.ACF.parameters.app.ap_url
; both would result in compile time errors
- If the type of
- Provides a singular central location to define configuration values
- Parameter types/logic can be extracted into a shard to be reused within multiple applications
- Provides a layer of abstraction between the parameters (what they are and how they’re provided) and the application
- Could switch from
ENV
vars to aYAML
file without changing any application code
- Could switch from
- Can support more complex types, e.g. Athena’s new base_uri parameter which is of type URI
- Also allows for applying custom logic to instantiate the types
- Parameters can be directly injected into services
- Makes testing easier as the service isn’t tightly coupled to
ENV
,ACF.parameters
, or a hardcoded value.
- Makes testing easier as the service isn’t tightly coupled to
# Tell ADI what parameter we wish to inject as the `app_url` argument.
# The value between the `%` represents the "path" to the value from the base `ACF::Parameters` type.
# ADI.bind may also be used to more easily share commonly injected parameters.
@[ADI::Register(_app_url: "%app.app_url%")]
class SomeService
def initialize(@app_url : String); end
end
By default the ACF::Parameters
and ACF::Base
types are instantiated directly by calling .new
on them. However, ACF.load_configuration
and/or ACF.load_parameters
methods can be redefined to change how each object is created.
# Overload the method that supplies the `ACF::Base` object to create it from a configuration file.
# NOTE: This of course assumes each configuration type includes `JSON::Serializable` or some other deserialization implementation.
def ACF.load_configuration : ACF::Base
# Use `File.read`, `File.open` could also have been used.
# NOTE: Both of these require the file be present with the built binary.
ACF::Base.from_json File.read "./config.json"
# Macro method `read_file` could also be used to embed the file contents in the binary.
ACF::Base.from_json {{read_file "./config.json"}}
end
I highly suggest checking out the config component documentation for a more detailed look.
Website
This release also iterated on the new website introduced last release, with the main feature being the integration of API documentation into the website, via the new mkdocstrings-crystal generator. Athena documentation is now presented in a more cohesive manner, with reference documentation linking directly to API types and vice versa.
Textual and API documentation has also been updated based on the other features of this release. Namely a new cookbook recipe showing off an example of how to serve static files.
File Handing Improvements
Given the configuration changes resulting in the removal of a file, I figured it would be appropriate to bring some file handling improvements to Athena to compensate. One of the more interesting new types is the ART::BinaryFileResponse. This type represents a static file that should be returned to the client. It supports Range requests and Conditional requests via the If-None-Match, If-Modified-Since, and If-Range headers. It also includes various options to enhance the response headers, such as automatically including an etag
or last-modified
header. Checkout the new cookbook recipe for how it can be used to serve static files.
The other newly added type is the ART::HeaderUtils. This type includes various HTTP
header utility methods, with the main one being .make_disposition which allows for easily creating a content-negotiation header for handling dynamic file downloads.
ART::Response.new(
file_contents,
headers: HTTP::Headers{"content-disposition" => ART::HeaderUtils.make_disposition(:attachment, "foo.pdf")}
)
DI Enhancements
In addition to the support for parameters, this release also introduces the concept of ADI::Proxy, which is used to represent a lazy service. Non proxy service dependencies are instantiated as they are being passed to .new
, even if it isn’t used. Proxies can be used to defer the instantiation of a service until it is used in some way, such as having a method call on it.
A service is proxied by changing the type signature of the service to be of the ADI::Proxy(T)
type, where T
is the service to be proxied.
@[ADI::Register]
class ServiceTwo
getter value = 123
def initialize
pp "new s2"
end
end
@[ADI::Register(public: true)]
class ServiceOne
getter service_two : ADI::Proxy(ServiceTwo)
# Tells `ADI` that a proxy of `ServiceTwo` should be injected.
def initialize(@service_two : ADI::Proxy(ServiceTwo))
pp "new s1"
end
def run
# At this point service_two hasn't been initialized yet.
pp "before value"
# First method interaction with the proxy instantiates the service and forwards the method to it.
pp @service_two.value
end
end
ADI.container.service_one.run
# "new s1"
# "before value"
# "new s2"
# 123
This same setup can be used when working with tagged services to provide the services as an Array(ADI::Proxy(T))
.
@[ADI::Register(_services: "!some_tag")]
class SomeService
def initialize(@services : Array(ADI::Proxy(ServiceType)))
end
end
The Negotiation Component
While this release does not include anything directly related to it, a new component has been developed and released. The Negotiation component allows an application to support content negotiation. The component has no dependencies and is framework agnostic; supporting various negotiators; such as character set, encoding, language, and MIME type.
negotiator = ANG.negotiator
accept_header = "text/html, application/xhtml+xml, application/xml;q=0.9"
priorities = {"text/html; charset=UTF-8", "application/json", "application/xml;q=0.5"}
accept = negotiator.best(accept_header, priorities).not_nil!
accept.media_range # => "text/html"
accept.parameters # => {"charset" => "UTF-8"}
This component will be integrated into Athena as part of the next release, so stay tuned!
Checkout the release notes for a complete list of changes. As usual feel free to join me in the Athena Gitter channel if you have any suggestions, questions, or ideas. I’m also available on Discord (Blacksmoke16#0016
) or via Email.