Athena 0.13.0

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 to Int32 or if the parameter’s name was typo’d, e.g. ACF.parameters.app.ap_url; both would result in compile time errors
  • 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 a YAML file without changing any application code
  • 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.
# 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.

6 Likes