Athena 0.10.0

Athena 0.10.0

This released focused on improving some QoL aspects of the framework as well as integrating the new Serializer component into Athena.

New Annotations

ART::View allows defining some configuration related to how an action result gets serialized. This includes any serialization Groups, if nil values should be serialized, and/or the HTTP::Status that should be used for the response.

class ExampleController < ART::Controller
  @[ART::Post("/user")]
  @[ART::View(status: :created)]
  def new_user(request : HTTP::Request) : User
    # Logic to create/save a user, ideally would use an `ART::ParamConverterInterface`
  end
end

# POST /user body: ... # => 201 Created

Previously the only way to use a custom status code was to use an ART::Response, but that made things a bit too verbose, and didn’t allow tapping into the view layer.

ART::Link and ART::Unlink brings native support for LINK and UNLINK endpoints. ART::Route can also be used for arbitrary HTTP methods.

class ExampleController < ART::Controller
  @[ART::Route("/some/path", method: "TRACE")]
  def trace_route : Nil
    # ...
  end
end

Serialization Component

The most exciting feature of this release is the integration of Athena’s Serializer component into the framework. Its integration is included with Athena itself, but is backwards compatible with JSON::Serializable. The serializer component is similar to JSON::Serializable, but with an expanded feature set, all controlled via annotations. Some highlights include:

  • ASRA::Name - Supporting different keys when deserializing versus serializing
  • ASRA::VirtualProperty - Allow a method to appear as a property upon serialization
  • ASRA::IgnoreOnSerialize - Allow a property to be set on deserialization, but should not be serialized (or vice versa)
  • ASRA::Expose - Allows for more granular control over which properties should be (de)serialized

The serializer component also introduces a few new concepts.

Exclusion Strategies

ASR::ExclusionStrategies::ExclusionStrategyInterface allow defining runtime logic to determine if a given property should be (de)serialized. By default two strategies are included, the Groups one mentioned earlier and ASR::ExclusionStrategies::Version. A future iteration of this feature will allow accessing custom annotations defined on the properties. An example use case for this would be to allow for IgnoreOnUpdate or IgnoreOnCreate annotations that would skip a property only if the current request is a PUT and/or POST request. Another example could be related to ACLs, to only expose information that the current user should see based on their permissions.

struct OddNumberExclusionStrategy
  include Athena::Serializer::ExclusionStrategies::ExclusionStrategyInterface

  # :inherit:
  #
  # Skips serializing odd numbered values
  def skip_property?(metadata : ASR::PropertyMetadataBase, context : ASR::Context) : Bool
    # Don't skip if the value is nil
    return false unless value = (metadata.value)

    # Only skip on serialization, if the value is an number, and if it's odd.
    context.is_a?(ASR::SerializationContext) && value.is_a?(Number) && value.odd?
  end
end

serialization_context = ASR::SerializationContext.new
serialization_context.add_exclusion_strategy OddNumberExclusionStrategy.new

record Values, one : Int32 = 1, two : Int32 = 2, three : Int32 = 3 do
  include ASR::Serializable
end

ASR.serializer.serialize Values.new, :json, serialization_context # => {"two":2}

Objection Construction

ASR::ObjectConstructorInterface determine how a new object is constructed during deserialization. A good use case for this would be souring an object from a database during a PUT request. This would allow the updated values to be applied to the existing object as opposed to either needing to create a whole new object from the request data or manually handle applying those changes. For example (taken from the Blog Demo Application):

# Define a custom `ASR::ObjectConstructorInterface` to allow sourcing the model from the database
# as part of `PUT` requests, and if the type is a `Granite::Base`.
#
# Alias our service to `ASR::ObjectConstructorInterface` so ours gets injected instead.
@[ADI::Register(alias: ASR::ObjectConstructorInterface)]
class DBObjectConstructor
  include Athena::Serializer::ObjectConstructorInterface

  # Inject `ART::RequestStore` in order to have access to the current request.
  # Also inject `ASR::InstantiateObjectConstructor` to act as our fallback constructor.
  def initialize(@request_store : ART::RequestStore, @fallback_constructor : ASR::InstantiateObjectConstructor); end

  # :inherit:
  def construct(navigator : ASR::Navigators::DeserializationNavigatorInterface, properties : Array(ASR::PropertyMetadataBase), data : ASR::Any, type)
    # Fallback on the default object constructor if the type is not a `Granite` model.
    unless type <= Granite::Base
      return @fallback_constructor.construct navigator, properties, data, type
    end

    # Fallback on the default object constructor if the current request is not a `PUT`.
    unless @request_store.request.method == "PUT"
      return @fallback_constructor.construct navigator, properties, data, type
    end

    # Lookup the object from the database; assume the object has an `id` property.
    object = type.find data["id"].as_i

    # Return a `404` error if no record exists with the given ID.
    raise ART::Exceptions::NotFound.new "An item with the provided ID could not be found." unless object

    # Apply the updated properties to the retrieved record
    object.apply navigator, properties, data

    # Return the object
    object
  end
end

# Make the compiler happy when we want to allow any Granite model to be deserializable.
class Granite::Base
  include ASR::Model
end

Other Minor Improvements

  • Added a POST endpoint example to the getting started documentation
  • Added a startup log message that includes the host and port the server will be listening on

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