Skip to content

Config

Athena includes the Athena::Config component as a means to configure an Athena application, which consists of two main aspects: ACF::Base and ACF::Parameters. ACF::Base relates to how a specific feature/component functions, e.g. the CORS Listener. ACF::Parameters represent reusable configuration values, e.g. a partner API URL for the current environment.

Basics#

Both configuration and parameters make use of the same high level implementation. A type is used to "model" the structure and type of each value, whether it's a scalar value like a String, or another object. These types are then added into the base types provided by Athena::Config. This approach provides full compile time type safety both in the structure of the configuration/parameters, but also the type of each value. It also allows for plenty of flexibility in how each object is constructed.

Tip

Structs are the preferred type to use, especially for parameters.

From an organizational standpoint, it is up to the user to determine how they wish to define/organize these configuration/parameter types. However, the suggested way is to use a central file that should require the individual custom types, for example:

# config/config_one.cr
record NestedParameters, id : Int32 = 1  

# Define a struct to store some parameters;
# a scalar value, and a nested object.
struct ConfigOne
  getter do_something : Bool = true
  getter nested_config : NestedConfig = NestedConfig.new

  getter special_value : Float64

  # Using getters with default values is the suggested way to handle simple/static types.
  # An argless constructor can also be used to apply more custom logic to what the values should be.
  def initialize
    @special_value = # ...
  end
end

# config/config_two.cr
record ConfigTwo, keys : Array(String) = ["a", "b", "c"]

# config.cr
require "./config/config_one"
require "./config/config_two"
# ...

# It is suggested to define custom parameter/configuration types within a dedicated namespace
# e.g. `app`, in order to avoid conflicts with built in types and/or third party shards.
struct MyApp
  getter config_one : ConfigOne = ConfigOne.new
  getter config_two : ConfigTwo = ConfigTwo.new
end

# Add our configuration type into the base type.
class ACF::Base
  getter app : MyApp = MyApp.new
end

The parameters and configuration can be accessed directly via ACF.parameters and ACF.config respectively. However there are better ways; direct access is (mostly) discouraged.

By default both ACF::Base and ACF::Parameters types are instantiated by calling .new on them without any arguments. However, ACF.load_configuration and/or ACF.load_parameters methods can be redefined to change how each object is created. An example of this could be deserializing a YAML, or other configuration type, file into the type itself.

# 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

Customizing Built-in Types#

While the process for defining/using custom configuration/parameter types is straightforward enough, an extra step is required to customize types owned by a third party shard, or Athena itself. The suggested approach is that customizable types expose a self.configure method that: returns nil (if the feature is optional), some preconfigured object (as an alias to .new with defaults), or not define one at all (if it should require the user implement it). This method would then be used in place of .new.

struct ThirdPartyParameters
  # Alias to `.new` for default values, but allow them to be customized.
  def self.configure : self
    new
  end

  getter email : String

  def initialize(@email : String = "[email protected]")
end

class Athena::Config::Parameters
  getter some_extension : ThirdPartyParameters = ThirdPartyParameters.configure
end

By default the some_extension.email parameter would be [email protected]. However if the user wanted to customize this value they could redefine the .configure method and supply their own values. Having a dedicated method to override allows the type to retain custom initializer logic without forcing the user to determine if they need to use previous_def.

def ThirdPartyParameters.configure
  new "[email protected]"
end

The user is free to use environmental variables or whatever other type of logic they wish to provide the custom values. The initializer of the type can also be referenced, such as to see what the configurable values are, their types, and any extra documentation provided by the owner.

Using Parent Values#

Due to the nature of how the configuration and parameter types are constructed, values defined elsewhere in the same base type cannot be access directly, e.g. having something like this would result in an infinite recursion error.

struct MyParameters
  getter admin_email : String = "[email protected]"
  getter nested_params : NestedParameters = NestedParameters.new
end

record NestedParameters, name : String = ACF.parameters.my_params.admin_email

class ACF::Parameters
  getter my_params : MyParameters = MyParameters.new
end 

The workaround to this is to pass the values down through the types, e.g.

struct MyParameters
  getter admin_email : String = "[email protected]"

  def initialize
    @nested_params = NestedParameters.new self
  end
end

struct NestedParameters
  @name : String

  def initialize(my_parameters : MyParameters)
    @name = my_parameters.admin_email
  end
end

class ACF::Parameters
  getter my_params : MyParameters = MyParameters.new
end

However, the recommended approach is to structure the types in such a way so that this is not required; such as by namespacing things less.

Configuration#

Configuration in Athena is mainly focused on "configuring" how specific features/components provided by Athena itself, or third parties, function at runtime. A more concrete example of the earlier section would be how ATH::Config::CORS can be used to control ATH::Listeners::CORS. Say we want to enable CORS for our application from our app URL, expose some custom headers, and allow credentials to be sent. To do this we would want to redefine the configuration type's self.configure method. This method should return an instance of self, configured how we wish. Alternatively, it could return nil to disable the listener, which is the default.

def ATH::Config::CORS.configure
  new(
    allow_credentials: true,
    allow_origin: %(https://app.example.com),
    expose_headers: %w(X-Transaction-ID X-Some-Custom-Header),
  )
end

Configuration objects may also be injected as you would any other service. This can be especially helpful for Athena extensions created by third parties whom services should be configurable by the end use. See the Configuration section in the DI component API documentation for details.

Parameters#

Parameters represent reusable values that are used to control the application's behavior, e.g. used within its configuration, or directly within the application's services. For example, the URL of the application is a common piece of information, used both in configuration and other services for redirects. This URl could be defined as a parameter to allow its definition to be centralized and reused.

Parameters should NOT be used for values that rarely change, such as the max amount of items to return per page. These types of values are better suited to being a constant within the related type. Similarly, infrastructure related values that change from one machine to another, e.g. development machine to production server, should be defined using environmental variables. However, these values may still be exposed as parameters.

Parameters are intended for values that do not change between machines, and control the application's behavior, e.g. the sender of notification emails, what features are enabled, or other high level application level values.

# Assume we added our `AppParams` type to the base `ACF::Parameters` type
# within our centralized configuration file, as mentioned in the "Basics" section.
struct AppParams
  # Define a getter for our app's URL, 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

We could now update the configuration from the earlier example to use this parameter.

def ATH::Config::CORS.configure : ATH::Config::CORS?
  new(
    allow_credentials: true,
    allow_origin: [ACF.parameters.app.app_url],
    expose_headers: %w(X-Transaction-ID X-Some-Custom-Header),
  )
end

With this change, the configuration is now decoupled from the current environment/location where the application is running. Common parameters could also be defined in their own shard in order to share the values between multiple applications.

It is also possible to access the same parameter directly within a service via a feature of the Dependency Injection component. See the Parameters section for details.

# 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

To reiterate, the primary benefit of parameters is to centralize and decouple their values from the types that actually use them. Another benefit is they offer full compile time safety, if for example, the type of app_url was mistakenly set to Int32 or if the parameter's name was typo'd, e.g. "%app.ap_url%"; both would result in compile time errors.

Note

The only valid usecases for accessing parameters directly via ACF.parameters is within a configuration type, or a type outside of Athena's control/DI framework.

Custom Annotations#

Athena integrates the Config component's ability to define custom annotation configurations. This feature allows developers to define custom annotations, and the data that should be read off of them, then apply/access the annotations on ATH::Controller and/or ATH::Actions.

This is a powerful feature that allows for almost limitless flexibility/customization. Some ideas include: storing some value in the request attributes, raise an exception, invoke some external service; all based on the presence/absence of it, a value read off of it, or either/both of those in-conjunction with an external service.

require "athena"

# Define our configuration annotation with an optional `name` argument.
# A default value can also be provided, or made not nilable to be considered required.
ACF.configuration_annotation MyAnnotation, name : String? = nil

# Define and register our listener that will do something based on our annotation.
@[ADI::Register]
class MyAnnotationListener
  include AED::EventListenerInterface

  @[AEDA::AsEventListener]
  def on_view(event : ATH::Events::View) : Nil
    # Represents all custom annotations applied to the current ATH::Action.
    ann_configs = event.request.action.annotation_configurations

    # Check if this action has the annotation
    unless ann_configs.has? MyAnnotation
      # Do something based on presence/absence of it.
      # Would be executed for `ExampleController#one` since it does not have the annotation applied.
    end

    my_ann = ann_configs[MyAnnotation]

    # Access data off the annotation.
    if my_ann.name == "Fred"
      # Do something if the provided name is/is not some value.
      # Would be executed for `ExampleController#two` since it has the annotation applied, and name value equal to "Fred".
    end
  end
end

class ExampleController < ATH::Controller
  @[ARTA::Get("one")]
  def one : Int32
    1
  end

  @[ARTA::Get("two")]
  @[MyAnnotation(name: "Fred")]
  def two : Int32
    2
  end
end

ATH.run

Pagination#

A good example use case for custom annotations is the creation of a Paginated annotation that can be applied to controller actions to have them be paginated via the listener. Generic pagination can be implemented via listening on the view event which exposes the value returned via the related controller action.

# Define our configuration annotation with the default pagination values.
# These values can be overridden on a per endpoint basis.
ACF.configuration_annotation Paginated, page : Int32 = 1, per_page : Int32 = 100, max_per_page : Int32 = 1000

# Define and register our listener that will handle paginating the response.
@[ADI::Register]
struct PaginationListener
  include AED::EventListenerInterface

  private PAGE_QUERY_PARAM     = "page"
  private PER_PAGE_QUERY_PARAM = "per_page"

  # Use a high priority to ensure future listeners are working with the paginated data
  @[AEDA::AsEventListener(priority: 255)]
  def on_view(event : ATH::Events::View) : Nil
    # Return if the endpoint is not paginated.
    return unless (pagination = event.request.action.annotation_configurations[Paginated]?)

    # Return if the action result is not able to be paginated.
    return unless (action_result = event.action_result).is_a? Indexable

    request = event.request

    # Determine pagination values; first checking the request's query parameters,
    # using the default values in the `Paginated` object if not provided.
    page = request.query_params[PAGE_QUERY_PARAM]?.try &.to_i || pagination.page
    per_page = request.query_params[PER_PAGE_QUERY_PARAM]?.try &.to_i || pagination.per_page

    # Raise an exception if `per_page` is higher than the max.
    raise ATH::Exceptions::BadRequest.new "Query param 'per_page' should be '#{pagination.max_per_page}' or less." if per_page > pagination.max_per_page

    # Paginate the resulting data.
    # In the future a more robust pagination service could be injected
    # that could handle types other than `Indexable`, such as
    # ORM `Collection` objects.
    end_index = page * per_page
    start_index = end_index - per_page

    # Paginate and set the action's result.
    event.action_result = action_result[start_index...end_index]
  end
end

class ExampleController < ATH::Controller
  @[ARTA::Get("values")]
  @[Paginated(per_page: 2)]
  def get_values : Array(Int32)
    (1..10).to_a
  end
end

ATH.run

# GET /values # => [1, 2]
# GET /values?page=2 # => [3, 4]
# GET /values?per_page=3 # => [1, 2, 3]
# GET /values?per_page=3&page=2 # => [4, 5, 6]