DEPRECATION NOTICE
This project is no longer maintaind by iZettle, please go to estum/api-blueprint for continued support.
DEPRECATION NOTICE
ApiBlueprint is a simple wrapper designed to be used in a Rails app for running http requests through Faraday and generating strongly-typed models from the JSON responses.
Example use
The examples below use the open notify astros api endpoint to list the astronauts who are current in space and which craft they are on.
Blueprints in models
Using ApiBlueprint::Model, you can define model classes with dry-types attributes and define blueprints which describe how an api call will be made.
# app/models/person.rb
class Person < ApiBlueprint::Model
attribute :name, Types::String
attribute :craft, Types::String
end
# app/models/astronauts_in_space.rb
class AstronautsInSpace < ApiBlueprint::Model
attribute :number, Types::Integer
attribute :people, Types::Array.of(Types.Constructor(Person))
def self.fetch
blueprint :get, "http://api.open-notify.org/astros.json"
end
end
Running blueprints
Blueprints can be run from controllers using an instance of ApiBlueprint::Runner
. You can use that runner instance to store session based information such as Authorization headers and such which need to be passed into requests.
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
def api
ApiBlueprint::Runner.new headers: { Authorization: "something" }
end
end
# app/controllers/astronauts_controller.rb
class AstronautsController < ApplicationController
def index
@astronauts = api.run AstronautsInSpace.fetch
end
end
The result of using api.run
on a blueprint is as you'd expect, nice model instances with the attributes set:
<!-- app/views/astronauts/index.html.erb -->
<h1>There are <%= @astronauts.number %> astronauts in space currently:</h1>
<ul>
<% @astronauts.each do |astronaut| %>
<li><%= astronaut.name %> is on <%= astronaut.craft %></li>
<% end %>
</ul>
Collections
Sometimes you might want a model which requires multiple api calls and collects the results onto different attributes. You can use an ApiBlueprint::Model.collection
for this.
class Vehicles < ApiBlueprint::Model
attribute :car, Types.Constructor(Car)
attribute :bus, Types.Constructor(Bus)
def self.fetch_all(color)
collection \
car: Car.all(color),
bus: Bus.all(color)
end
end
# Example use
red_vehicles = api.run Vehicles.fetch_all("red")
red_vehicles.cars # [<Car>, <Car>, ...]
red_vehicles.busses # [<Bus>, <Bus>, ...]
Request registry
If you use the same api request in multiple controllers, it can be cumbersome to remember to set the cache options and pass all required params to api calls. ApiBlueprint includes a registry, which can be used as a container to store blueprints along with cache options and make it quicker and simpler to re-use in controllers.
You can add to the registry when initialing the api runner, or later.
# Add `astronauts_in_space` to the registry when initializing the runner:
api = ApiBlueprint::Runner.new registry: {
astronauts_in_space: { blueprint: -> { AstronautsInSpace.fetch }, cache: { ttl: 10.minutes } }
}
# Add `vehicles` to the existing registry:
api.register :vehicles, -> { Vehicles.fetch_all }, ttl: 60.minutes
Once a blueprint is registered in the registry, you can invoke it via the key name on the runner:
api.astronauts_in_space # the same as running api.run AstronautsInSpace.fetch, ttl: 10.minutes
api.vehicles # the same as running api.run Vehicles.fetch_all, ttl: 60.minutes
Model Configuration
Using a configure
block on models, you can define a default url (host), a parser, a builder and can define a list of replacements:
class AstronautsInSpace < ApiBlueprint::Model
configure do |config|
config.host = "http://api.open-notify.org"
config.parser = CustomResponseParser.new
config.builder = CustomObjectBuilder.new
config.replacements = {}
end
end
Config.builder
When running a blueprint, after the response is returned and parsed, the result is passed to a builder, which is responsible for initializing objects from the response. The default ApiBlueprint::Builder will pass the attributes from the response into the initializer for the class the blueprint was defined in.
If you want to change the behavior of the builder, or have a complex response which needs manipulation before it should be passed to the initializer, you can define a custom builder. Custom builders must inherit from the default builder, and can override any combination of the core methods which are used to build responses; build
, prepare_item
, and build_item
. Refer to the default builder to see what those methods do.
Config.parser
The parser is responsible for taking the raw response body string and generating a useful object from it, which can be passed into the builder to generate instances of the model. The default parser is used to parse json strings and return a hash.
If you need a custom parser (for example, an XML parser), you must define a class which inherits from ApiBlueprint::Parser
, and overrides the #parse
method.
Config.replacements
Replacements can be used to handle poorly named keys in api responses, or to re-word things without the need to creating a custom builder. For example, if the api by default returned a key called numberOfAstronautsInSpace
and you wanted this to assign the number
attribute on the model, you could use a replacement to handle that:
config.replacements = {
numberOfAstronautsInSpace: :number
}
Validation
You can use active model validations on models to validate body payloads. This is useful to pre-check user input before sending API requests. It is disabled by default, but to enable, you just need to set validate: true
on your blueprint definitions:
class Astronaut < ApiBlueprint::Model
attribute :name, Types::String
validates :name, presence: true
def self.send_to_space(name)
blueprint :post, "/space", body: { name: name }, validate: true
end
end
Astronaut.send_to_space(nil) # => <ActiveModel::Errors ...>
Behind the scenes, ApiBlueprint uses the body hash to initialize a new instance of your model, and then runs validations. If there are any errors, the API request is not run and the errors object is returned.
Error handling
If an API response includes an errors
object, ApiBlueprint uses it to assign ActiveModel::Errors
instances on the class which is built. This way, validation errors which come an the API behave exactly the same as validation errors set locally through validations on the model.
Certain response statuses will also cause ApiBlueprint to behave in different ways:
HTTP Status range | Behavior |
---|---|
200 - 400 | Objects are built normally, no errors raised |
401 | raises ApiBlueprint::UnauthenticatedError
|
404 | raises ApiBlueprint::NotFoundError
|
402 - 499 | raises ApiBlueprint::ClientError
|
500 - 599 | raises ApiBlueprint::ServerError
|
Additionally, if the request has an error which prevents the request from ever receiving a response, an ApiBlueprint::ConnectionFailed
error will be raised. If a request times out, an ApiBlueprint::TimeoutError
error will be raised.
## Access to response headers and status codes
By default, ApiBlueprint tries to set response_headers
and response_status
on the model which is created from an API response. ApiBlueprint::Model
also has a convenience method api_request_success?
which can be used to easily assert whether a response was in the 200-399 range. This makes it simple to render different responses in controllers. For example:
# app/controllers/astronauts_controller.rb
class AstronautsController < ApplicationController
def index
@astronauts = api.run AstronautsInSpace.fetch
if @astronauts.api_request_success?
render json: @astronauts
else
render json: @astronauts.errors, status: :bad_request
end
end
end
Blueprint options
When defining a blueprint in a model, you can pass it a number of options to set request headers, params, body, or to run code after an instance of the model has been initialized. Here's some examples:
# Most basic usage
blueprint :get, "/endpoint"
# Different http methods are supported (:get, :post, :put, :delete, :head, :patch, :options)
blueprint :put, "/endpoint"
# Request headers, body, or params can be passed along
blueprint :post, "/endpoint", {
headers: { "Content-Type": "application/json" },
params: { hello: "world" },
body: { something: "in the body" }
}
# If you need to modify the instance which will be returned, or run subsequent requests using
# the runner, you can do so in a block. Note, this is the only place the runner will be available
# when running the blueprint.
blueprint :get, "/endpoint" do |runner, result|
result.tap do |astronaut|
astronaut.number = 23 # override something
astronaut.more_info = runner.run SomeOtherModel.fetch # run another request
end
end
Response logging
Response logging can be enabled on a per-blueprint level, or by setting config.log_responses = true
on an ApiBlueprint::Model
:
class AstronautsInSpace < ApiBlueprint::Model
configure do |config|
# enable logging for all blueprints
config.log_responses = true
end
def self.fetch
# enable logging for just one blueprint
blueprint :get, "http://api.open-notify.org/astros.json", log_responses: true
end
end
Caching
ApiBlueprint includes the ability to cache responses and avoid numerous api calls to endpoints, but does not implement a caching mechanism itself. Instead it exposes a skeleton cache class which you can override with your own caching mechanism. See the Rails cache example for an example implementation using Rails.cache.write
, Rails.cache.read
, etc.
Caching is enabled on the runner level. In this case, using the Rails session id to make the cache unique to each user:
ApiBlueprint::Runner.new({
cache: BlueprintCache.new(key: session.id)
})
The ApiBlueprint::Cache
class has a method to generate unique keys for the cache items by creating a checksum of the request headers and url. It doesn't include the body of the request in this checksum by default, and if you want to exclude headers, you can do so using the ignored_headers
setting on the Cache class.
For example, to not include "X-Real-IP" and "X-Request-Id" headers, which would otherwise render the cache useless:
ApiBlueprint::Cache.configure do |config|
config.ignored_headers = ["X-Real-IP", "X-Request-Id"]
end
If you would like to override how the cache keys are generated, you can do it for all classes by redefining generate_cache_key
in your cache class. If you only need to override the cache key for certain models (for example, a public api cache might not want to use the session id to make the cache keys unique), you can do so by implementing a cache_key_generator
proc on your model config:
class AstronautsInSpace < ApiBlueprint::Model
configure do |config|
config.cache_key_generator = -> (key, options) do
# `key` is the key which you have initialized the instance of the cache class with
# `options` is all the api-related options for the request (url, headers, body, etc)
"some-custom-cache-key-here"
end
end
end
Timeouts
The default request timeout is set to 5 seconds. You can change this on a per-blueprint basis by passing the timeout
option to the blueprint:
blueprint :get, "/endpoint", timeout: 10.seconds
A note on Dry::Struct immutability
Models you create use Dry::Struct
to handle initialization and assignment. Dry::Struct
is designed with immutability in mind, so if you need to mutate the objects you have, there are two possibilities; explicitly define an attr_writer
for the attributes which you want to mutate, or do things the "Dry::Struct way" and use the current instance to initialize a new instance:
astros = AstronautsInSpace.new number: 5, foo: "bar"
astros.number # => 5
astros.number = 10 # NoMethodError: undefined method `number=' for #<AstronautsInSpace number=5 foo="bar">
new_astros = astros.new number: 10
new_astros # #<AstronautsInSpace number=10 foo="bar">