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?
require 'requisite'
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
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: Integer,
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 only applies one level deep; beyond that we recommend you use a nested ApiModel that's well structured.
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: Integer, 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 {}
.
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 []
.
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
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
An around_each_attribute
method can be defined to wrap each attribute fetch in a block. This can be useful for instrumenting processing on a per attribute basis.
class ApiUser < Requisite::ApiModel
serialized_attributes do
attribute :id, type: String
attribute :email, type: String
end
def around_each_attribute(name, &block)
start = Time.now
yield
end = Time.now
puts "Fetching #{name} took #{end - start}"
end
end
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.