The Crystal Programming Language Forum

Athena 0.11.0

Athena 0.11.0

This release focused on improving the flexibility of the framework through custom annotations as well as integrating the new Validator component into Athena.

Validation Component

The most exciting feature of this release is the integration of Athena’s Validator component into the framework. Its integration is included with Athena itself in order to be ready to go when/if the need arises.

Intro

The framework consists of AVD::Constraints that describe some assertion; such as a string should be AVD::Constraints::NotBlank or that a value is AVD::Constraints::GreaterThanOrEqual another value. Constraints, along with a value, are then passed to an AVD::ConstraintValidatorInterface that actually performs the validation, using the data defined in the constraint. If the validator determines that the value is invalid in some way, it creates and adds an AVD::Violation::ConstraintViolationInterface to this runs’ AVD::ExecutionContextInterface. The AVD::Validator::ValidatorInterface then returns an AVD::Violation::ConstraintViolationListInterface that contains all the violations. The value can be considered valid if that list is empty.

# Obtain a validator instance.
validator = AVD.validator

# Use the validator to validate a value.
violations = validator.validate "foo", AVD::Constraints::NotBlank.new

Instead of returning a scalar Bool indicating a if a value is valid or not, the validator returns an array of violation objects. Each violation contains data about the failure; such as the constraint that failed, the invalid value itself, the path to the invalid value, and any parameters that should be used to render the message.

# Constraints also suppor custom messages, with placeholder values
violations = validator.validate -4, AVD::Constraints::PositiveOrZero.new message: "{{ value }} is not a valid age.  A user cannot have a negative age."

violations.inspect =>
# Athena::Validator::Violation::ConstraintViolationList(
#   @violations=[
#     Athena::Validator::Violation::ConstraintViolation(Int32)(
#       @cause=nil,
#       @code="e09e52d0-b549-4ba1-8b4e-420aad76f0de",
#       @constraint=
#         #<Athena::Validator::Constraints::PositiveOrZero:0x7fd3943ecea0
#           @groups=["default"],
#           @message="{{ value }} is not a valid age.  A user cannot have a negative age.",
#           @payload=nil,
#           @value=0,
#           @value_type=Int32>,
#       @invalid_value_container=Athena::Validator::ValueContainer(Int32)(@value=-4),
#       @message="-4 is not a valid age.  A user cannot have a negative age.",
#       @message_template="{{ value }} is not a valid age.  A user cannot have a negative age.",
#       @parameters={"{{ value }}" => "-4", "{{ compared_value }}" => "0","{{ compared_value_type }}" => "Int32"},
#       @plural=nil,
#       @property_path="",
#       @root=-4)
#   ]
# )

# Each violation and the violation list support `#to_s` methods.
puts violations # =>
#-4:
#  -4 is not a valid age.  A user cannot have a negative age. (code: e09e52d0-b549-4ba1-8b4e-420aad76f0de)

Objects can also be validated:

# Define a class that can be validated.
class User
  include AVD::Validatable

  def initialize(@name : String, @age : Int32? = nil); end

  # Specify that we want to assert that the user's name is not blank.
  # Multiple constraints can be defined on a single property.
  @[Assert::NotBlank]
  getter name : String

  # Arguments to the constraint can be used normally as well.
  # The constraint's default argument can also be supplied positionally: `@[Assert::GreaterThan(0)]`.
  @[Assert::NotNil(message: "A user's age cannot be null")]
  getter age : Int32?
end

# Validate a user instance, notice we're not passing in any constraints.
validator.validate(User.new("Jim", 10)).empty? # => true
validator.validate User.new "", 10             # =>
# Object(User).name:
#   This value should not be blank. (code: 0d0c3254-3642-4cb0-9882-46ee5918e6e3)

Constraint annotations can be applied to classes, structs, instance variables, and getter methods.

Groups

Validation Groups can also be used to only validate certain properties:

class User
  include AVD::Validatable

  def initialize(@email : String, @city : String); end

  @[Assert::Email(groups: "create")]
  getter email : String

  @[Assert::Size(2..)]
  getter city : String
end

user = User.new "george@dietrich.app", ""

# Validate the user object, but only for those in the "create" group,
# if no groups are supplied, then all constraints in the "default" group will be used.
violations = AVD.validator.validate user, groups: "create"

# There are no violations since the city's size is not validated since it's not in the "create" group.
violations.empty? # => true

An AVD::Constraints::GroupSequence can also be used to validate the groups in steps, only continuing to the next group if there are no violations. AVD::Constraints::GroupSequence::Provider can be used to determine the group sequence dynamically at runtime. AVD::Constraints::Sequentially provides a more straightforward method of applying constraints sequentially on a single property.

Custom Constraints

If the provided constraints are not enough, or you are able to create your own constraints & validators.

# NOTE: The constraint MUST be defined within the AVD::Constraints namespace for implementation reasons. This may change in the future.
class AVD::Constraints::AlphaNumeric < AVD::Constraint
  # Define an initializer with our default message, and any additional arguments specific to this constraint.
  def initialize(
    message : String = "This value should contain only alphanumeric characters.",
    groups : Array(String) | String | Nil = nil,
    payload : Hash(String, String)? = nil
  )
    super message, groups, payload
  end

  # Define the validator within our constraint that'll contain our validation logic.
  struct Validator < AVD::ConstraintValidator
    # Define our validate method that accepts the value to be validated, and the constraint.
    #
    # Overloads can be used to filter values of specific types.
    def validate(value : _, constraint : AVD::Constraints::AlphaNumeric) : Nil
      # Custom constraints should ignore nil and empty values to allow
      # other constraints (NotBlank, NotNil, etc.) take care of that
      return if value.nil? || value == ""

      # We'll cast the value to a string,
      # alternatively we could just ignore non `String?` values.
      value = value.to_s

      # If all the characters of this string are alphanumeric, then it is valid
      return if value.each_char.all? &.alphanumeric?

      # Otherwise, it is invalid and we need to add a violation,
      # see `AVD::ExecutionContextInterface` for additional information.
      self.context.add_violation constraint.message, NOT_ALPHANUMERIC_ERROR, value
    end
  end
end

# It could also be used as an annotation, `@[Assert::AlphaNumeric]`.
puts AVD.validator.validate "foo$bar", AVD::Constraints::AlphaNumeric.new # =>
# foo$bar:
#   This value should contain only alphanumeric characters. (code: 1a83a8bd-ff79-4d5c-96e7-86d0b25b8a09)

Testing

I used the validator component to pilot a new pattern within the Athena ecosystem regarding an often forgotten aspect of developing an application: testing. The validator component includes a AVD::Spec module that includes helpful/common types when testing validator related features. This modules leverages Athena::Spec::TestCase to provide a simplistic way to test custom constraint validators: AVD::Spec::ConstraintValidatorTestCase.

My plan is to expand this module to other components, allowing each component to expose testing utilities that were previously private as part of their spec_helper file.

Annotation Configurations

Another major element of this release is the ability to define and apply custom annotations to your controllers and/or route actions.

# Define a custom annotation configuration, along with any values that can be read off of it
# `priority` is required since it does not have a default, while `active` is optional.
ACF.configuration_annotation MyAnnotation, priority : Int32, active : Bool = true

class ExampleController < ART::Controller
  @[MyAnnotation(priority: 10)]
  get "/hello" do
    "hello"
  end

  # Override the default value of `active`.
  @[MyAnnotation(priority: 5, active: false)]
  get "/goodbye" do
    "goodbye"
  end
end

# The values within the annotation can then be read at runtime within a listener
@[ADI::Register]
struct MyANnotationListener
  include AED::EventListenerInterface

  def self.subscribed_events : AED::SubscribedEvents
    AED::SubscribedEvents{
      ART::Events::Action => 24, # Runs after the action has been resolved, but before executing it.
    }
  end

  def call(event : ART::Events::Action, dispatcher : AED::EventDispatcherInterface) : Nil
    # Get access to the annotation configurations on the action related to this request.
    ann_configs = event.request.action.annotation_configurations

    # Nothing to do if the action doesn't have our annotation.
    return unless config = ann_configs[MyAnnotation]?
    
    # Do something with the config object.
    if config.priority >= 0
      # ...
    end
  end
end

See the ART::Events::RequestAware type for more information.

New Event

A sharp eyed user may have noticed that the listener in the previous example is using a new event. The ART::Events::Action event is emitted after the Request event but before the action is executed. This can be useful for listeners that require information about the resolved route, such as for reading custom annotation configurations.

Athena Wiki

I have create/added some initial content to Athena’s wiki on Github. One of the main features of this currently is the introduction of a cookbook section. This includes various types, such as event listeners, param converters, and exclusion strategies that may be useful to your project, but are too specific to be included in Athena itself.

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.

Blog posts and the Blog Tutorial repo will be updated shortly as well.

3 Likes