Requisite

Requisite is an elegant way of strongly defining request and response models for serialization. How nice would it be if you could do:

def create
  api_user = ApiRequestUser.new(params)
  user = User.create(api_user.to_hash)
  render json: ApiResponseUser.new(user).to_json
end

Without worrying about strong parameters, type safety and keeping a consistent API?

Usage

require 'requisite'

ApiModel

ApiModels are the primary way of using Requisite, they represent a model defined as part of an API. Attributes can be listed within a serialized_attributes block, with the format <attribute-type> <attribute-name> <options>.

method behaviour
attribute The attribute with the given name will be looked up on the model, nil if not found. If a method with the same name exists on the UserResponse object it will be called for a value instead. Can take several options. Aliased to a.
attribute! as attribute, but raises an error if not found on model. Aliased to a!.

ApiModels can be constructed from other objects, or from Hashes (like those you might find in params). The helper method attribute_from_model(:attribute_name) gives access that will work with either.

These objects have methods to access, and can be serialized back to a Hash (post-transformation; with non-listed parameters removed), or directly to json.

class UserApiModel < Requisite::ApiModel
  serialized_attributes do
    attribute! :id
    attribute! :username
    attribute :real_name
  end

  # method with the name of of an attribute will be called to calculate the mapped value
  def real_name
    "#{attribute_from_model(:first_name)} #{attribute_from_model(:last_name)}"
  end
end

current_user = User.new(:id => 5, :first_name => 'Jamie', :last_name => 'Osler', :username => 'josler')
user = UserApiModel.new(current_user)
user.username
# => 'josler'
user.real_name
# => 'Jamie Osler'
user.to_hash
# => { :id => 5, :real_name => 'Jamie Osler', :username => 'josler' }
user.to_json
# => "{\"id\":5,\"real_name\":\"Jamie Osler\",\"username\":\"josler\"}"

nil values are not returned in the response, unless to_hash(show_nil: true) or to_json(show_nil: true) are requested.

Errors are thrown when a required attribute is not present:

UserApiModel.new({:id => 5, :first_name => 'Jamie', :last_name => 'Osler'}).to_hash
# => Requisite::NotImplementedError: 'username' not found on model

Options

There are several options that can be used with ApiModel attributes:

option behaviour
default value will be used as a default if the attribute is not found. Not available for attribute!
stringify .to_s will be called on value
rename The returned value will be sourced from the model's value attribute
type Raises error if value does not match given type. Works on the model's value prior to stringification and renaming. Nils are excluded.
scalar_hash Attribute is a hash with only scalar values permitted - Numeric, String, TrueClass and FalseClass types.
typed_hash Attribute is a typed hash, with value a hash specifying a mapping of sub-attribute to types.
typed_array Attribute is a typed array, with value specifying the type of elements within the array

They can also be combined.

Example:

class UserApiModel < Requisite::ApiModel
  serialized_attributes do
    attribute :id, stringify: true
    attribute :custom_attributes, rename: :custom_data 
    attribute :is_awesome, default: true
    attribute :awesome_score, rename: :score, stringify: true, default: 9001
    attribute :age, type: Fixnum,
    attribute :tired, type: Requisite::Boolean
  end
end

current_user = User.new(:id => 5, :custom_data => [ {:number_events => 4} ], :age => 26)
UserApiModel.new(current_user).to_json
# => "{\"id\":\"5\",\"custom_attributes\":[{\"number_events\":4}],\"is_awesome\":true,\"awesome_score\":\"9001\",\"age\":26}"

The Requisite::Boolean type will match TrueClass and FalseClass.

Nested Structure Support

Nested structure support only applies one level deep; beyond that we recommend you use a nested ApiModel that's well structured.

Hashes

ApiModels support nested hashes in two forms; specifying that a Hash should contain only Scalar (Numeric, String and Boolean) values, or a nested hash of a typed attributes.

With scalar hashes, any scalar value is permitted:

class UserApiModel < Requisite::ApiModel
  serialized_attributes do
    attribute :data, scalar_hash: true
  end
end

UserApiModel.new(:data => {:is_awesome => true, :score => 9001, :name => 'Jamie'}).to_hash
# => { :data => {:is_awesome => true, :score => 9001, :name => 'Jamie'} }

Non-scalar values will raise a Requisite::BadTypeError. Empty scalar hash attributes are returned as {}.

With typed hashes, only values specified with a type are permitted:

class UserApiModel < Requisite::ApiModel
  serialized_attributes do
    attribute :data, typed_hash: { is_awesome: Requisite::Boolean, score: Fixnum, name: String  }
  end
end

UserApiModel.new(:data => {:is_awesome => true, :score => 9001, :name => 'Jamie'}).to_hash
# => { :data => {:is_awesome => true, :score => 9001, :name => 'Jamie'} }

Note that setting the type to the provided Requisite::Boolean permits TrueClass and FalseClass values.

Fields within a fixed hash that are not listed as permitted will be omitted (even with attribute! their presence will not raise an error).

Fields with the wrong data type will result in a Requisite::BadTypeError being raised. Empty typed hash attributes are returned as {}.

Arrays

Typed arrays are supported; arrays must be all of one type:

class UserApiModel < Requisite::ApiModel
  serialized_attributes do
    attribute :ids, typed_array: String
  end
end

UserApiModel.new(:ids => ['x123D', 'u71d', '96yD']).to_hash
# => { :ids => ['x123D', 'u71d', '96yD'] }

Array values not corresponding to the correct type will raise a Requisite::BadTypeError. Empty Array attributes will be returned as [].

Advanced Nested Structures

To work with advanced nested structures, we recommend you create a method with the attribute name that will be called, and use another ApiModel to perform validation, for example:

class ApiUser < Requisite::ApiModel
  serialized_attributes do
    attribute :id, type: String
    attribute :company
  end

  # ApiCompany object handles its' own validation
  def company
    ApiCompany.new(attribute_from_model(:company)).to_hash
  end
end

Preprocess Request

A preprocess_model method can be defined to carry out any required steps before the model is processed, e.g.:

class ApiUser < Requisite::ApiModel
  serialized_attributes do
    attribute :id, type: String
    attribute :email, type: String
  end

  # preprocess to check we have an identifier for the user
  def preprocess_model
    identifier = attribute_from_model(:id)
    identifier ||= attribute_from_model(:email)
    raise IdentifierNotFoundError unless identifier
  end
end

Thanks

Strongly inspired by the work done in the mutations gem, and with restpack_serializer, as well as some of the patterns laid out in Robert Martin's demonstrations of clean architecture.