Skip to content

Param Converters

Param Converters allow applying custom logic in order to convert one or more primitive request parameter into a more complex type.

DB#

In a REST API, endpoints usually contain a reference to the id of the object in question; e.x. GET /user/10. A useful converter would be able to extract this ID from the path, lookup the related entity, and provide that object directly to the controller action. This reduces the boilerplate associated with doing a DB lookup within every controller action. It also makes testing easier as it abstract the logic of how that object is resolved from what should be done to it.

This example uses the Granite ORM, but should work with others. Alternatively a DTO may be used to keep (de)serialization and validation logic separate from the actual models.

# Define an register our param converter as a service.
@[ADI::Register]
struct DBConverter < ART::ParamConverterInterface
  # Define a customer configuration for this converter.
  # This allows us to provide a `entity` field within the annotation
  # in order to define _what_ entity should be queried for.
  configuration entity : Granite::Base.class

  # :inherit:
  #
  # Be sure to handle any possible exceptions here to return more helpful errors to the client.
  def apply(request : HTTP::Request, configuration : Configuration) : Nil
    # Grab the `id` path parameter from the request's attributes as an Int32.
    primary_key = request.attributes.get "id", Int32

    # Raise a `404` error if a record with the provided ID does not exist.
    # This assumes there is a `.find` method on the related entity class.
    raise ART::Exceptions::NotFound.new "An item with the provided ID could not be found" unless entity = configuration.entity.find primary_key

    # Set the resolved entity within the request's attributes
    # with a key matching the name of the argument within the converter annotation.
    request.attributes.set configuration.name, model, configuration.entity
  end
end

class Article < Granite::Base
  connection "default"
  table "articles"

  column id : Int64, primary: true
  column title : String
end

@[ARTA::Prefix("article")]
class ExampleController < ART::Controller
  @[ARTA::Get("/:id")]
  @[ARTA::ParamConverter("article", converter: DBConverter, entity: Article)]
  def get_article(article : Article) : Article
    # Nothing else to do except return the releated article.
    article
  end
end

# Run the server
ART.run

# GET /article/1    # => {"id":1,"title":"Article A"}
# GET /article/5    # => {"id":5,"title":"Article E"}
# GET /article/-123 # => {"code":404,"message":"An item with the provided ID could not be found."}

Tada. We now testable, reusable logic to provide database objects directly as arguments to our controller action. Since the entity class is specified on the annotation, the actual converter can be reused for multiple actions and multiple entity classes.

Request Body#

Similar to the DB converter, another common practice is deserializing a request's body into an object.

NOTE: This examples uses the Granite ORM, but should work with others.

# Define an register our param converter as a service.
@[ADI::Register]
struct RequestBody < ART::ParamConverterInterface
  # Define a customer configuration for this converter.
  # This allows us to provide a `entity` field within the annotation
  # in order to define _what_ entity should be queried for.
  configuration entity : Granite::Base.class

  # Inject the serializer and validator into our converter.
  def initialize(
      @serializer : ASR::SerializerInterface,
      @validator : AVD::Validator::ValidatorInterface,
  ); end

  # :inherit:
  def apply(request : HTTP::Request, configuration : Configuration) : Nil
    # Be sure to handle any possible exceptions here to return more helpful errors to the client.
    raise ART::Exceptions::BadRequest.new "Request body is empty." unless body = request.body

    # Deserialize the object, based on the type provided in the annotation.
    object = @serializer.deserialize configuration.entity, body, :json

    # Validate the object if it is validatable.
    if object.is_a? AVD::Validatable
      errors = @validator.validate object
      raise AVD::Exceptions::ValidationFailed.new errors unless errors.empty?
    end

    # Add the resolved object to the request's attributes.
    request.attributes.set configuration.name, object, configuration.entity
  end
end

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

class Article < Granite::Base
  connection "default"
  table "articles"

  column id : Int64, primary: true
  column title : String
end

@[ARTA::Prefix("article")]
class ExampleController < ART::Controller
  @[ARTA::Post(path: "")]
  @[ARTA::View(status: :created)]
  @[ARTA::ParamConverter("article", converter: RequestBody, model: Article)]
  def new_article(article : Article) : Article
    # Since we have an actual `Article` instance, we can simply save and return the article.
    article.save
    article
  end
end

We can now easily save new entities, and be assured they are valid by running validations as well within our converter. However what about updating an entity? The Serializer component has the concept of Object Constructors that determine how a new object is constructed during deserialization. This feature allows updated values to be applied to an existing object as opposed to either needing to create a whole new object from the request data or manually handle applying those changes.

# 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.
    entity = type.find data["id"].as_i64

    # 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 entity

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

    # Return the entity
    entity
  end
end

The Validator component could also be injected into the param converter to run validations after deserialzing an object.