Summary
Toast is a Rack application that hooks into Ruby on Rails. It exposes ActiveRecord models as a web service (REST API). The main difference from doing that with Ruby on Rails itself is it's DSL that covers all aspects of an API in one single configuration. For each model and API endpoint you define:
- what models and attributes are to be exposed
- what methods are supported (GET, PATCH, DELETE, POST,...)
- hooks to handle authorization
- customized handlers
When using Toast there's no Rails controller involved. Model classes and the API configuration is sufficient.
Toast uses a REST/hypermedia style API, which is an own interpretation of the REST idea, not compatible with others like JSON API, Siren etc. It's design is much simpler and based on the idea of traversing opaque URIs.
Other features are:
- windowing of collections via Range/Content-Range headers (paging)
- attribute selection per request
- processing of URI parameters
See the User Manual for a detailed description.
Releases
Toast version ≥ 1.0.2
Works with Rails from version 4.2.9+ up to 6. This version will be tested with upcoming new Rails releases and receives bugfixes and new features.
Toast version 0.9.*
Works with Rails 3 and 4. It has a much different and smaller DSL, which is not compatible with v1. This version will not receive any updates or fixes anymore.
Installation
with Bundler (Gemfile) from Rubygems:
source 'http://rubygems.org'
gem "toast"
from Github:
gem "toast", :git => "https://github.com/robokopp/toast.git"
then run
bundle
rails generate toast init
create config/toast-api.rb
create config/toast-api
Example
Let the table bananas have the following schema:
create_table "bananas", :force => true do |t|
t.string "name"
t.integer "number"
t.string "color"
t.integer "apple_id"
end
and let a corresponding model class have this code:
class Banana < ActiveRecord::Base
belongs_to :apple
has_many :coconuts
scope :less_than_100, -> { where("number < 100") }
end
Then we can define the API like this (in config/toast-api/banana.rb
):
expose(Banana) {
readables :color
writables :name, :number
via_get {
allow do |user, model, uri_params|
true
end
}
via_patch {
allow do |user, model, uri_params|
true
end
}
via_delete {
allow do |user, model, uri_params|
true
end
}
collection(:less_than_100) {
via_get {
allow do |user, model, uri_params|
true
end
}
}
collection(:all) {
max_window 16
via_get {
allow do |user, model, uri_params|
true
end
}
via_post {
allow do |user, model, uri_params|
true
end
}
}
association(:coconuts) {
via_get {
allow do |user, model, uri_params|
true
end
handler do |banana, uri_params|
if uri_params[:max_weight] =~ /\A\d+\z/
banana.coconuts.where("weight <= #{uri_params[:max_weight]}")
else
banana.coconuts
end.order(:weight)
end
}
via_post {
allow do |user, model, uri_params|
true
end
}
via_link {
allow do |user, model, uri_params|
true
end
}
}
association(:apple) {
via_get {
allow do |user, model, uri_params|
true
end
}
}
}
Note, that all allow-blocks in the above example return true. In practice authorization logic should be applied. An allow-block must be defined for each endpoint because it defaults to return false, which causes a 401 Unauthorized response.
The above definition exposes the model Banana as such:
Get a single resource representation:
GET http://www.example.com/bananas/23
--> 200, '{"self": "http://www.example.com/bananas/23"
"name": "Fred",
"number": 33,
"color": "yellow",
"coconuts": "http://www.example.com/bananas/23/coconuts",
"apple": "http://www.example.com/bananas/23/apple" }'
The representation of a record is a flat JSON map: name → value, in case of associations name → URI. The special key self contains the URI from which this record can be fetch alone. self can be treated as a unique ID of the record (globally unique, if under a FQDN).
Get a collection (the :all collection)
GET http://www.example.com/bananas
--> 200, '[{"self": "http://www.example.com/bananas/23",
"name": "Fred",
"number": 33,
"color": "yellow",
"coconuts": "http://www.example.com/bananas/23/coconuts",
"apple": "http://www.example.com/bananas/23/apple,
{"self": "http://www.example.com/bananas/24",
... }, ... ]'
The default length of collections is limited to 42, this can be adjusted globally or for each endpoint separately. In this case no more than 16 will be delivered due to the max_window 16
directive.
Get a customized collection
GET http://www.example.com/bananas/less_than_100
--> 200, '[{BANANA}, {BANANA}, ...]'
Any scope or class method returning a relation can be published this way.
Get an associated collection + filter
GET http://www.example.com/bananas/23/coconuts?max_weight=3
--> 200, '[{COCONUT},{COCONUT},...]',
The COCONUT model must be exposed too. URI parameters can be processed in custom handlers for sorting and filtering.
Update a single resource:
PATCH http://www.example.com/bananas/23, '{"name": "Barney", "number": 44, "foo" => "bar"}'
--> 200, '{"self": "http://www.example.com/bananas/23"
"name": "Barney",
"number": 44,
"color": "yellow",
"coconuts": "http://www.example.com/bananas/23/coconuts",
"apple": "http://www.example.com/bananas/23/apple"}'
Toast ingores unknown attributes, but prints warnings in it's log file. Only attributes from the 'writables' list will be updated.
Create a new record
POST http://www.example.com/bananas, '{"name": "Johnny", "number": 888}'
--> 201, '{"self": "http://www.example.com/bananas/102"
"name": "Johnny",
"number": 888,
"color": null,
"coconuts": "http://www.example.com/bananas/102/coconuts",
"apple": "http://www.example.com/bananas/102/apple }'
Create an associated record
POST http://www.example.com/bananas/23/coconuts, '{COCONUT}'
--> 201, {"self":"http://www.example.com/coconuts/432, ...}
Delete records
DELETE http://www.example.com/bananas/23
--> 200
Linking records
LINK "http://www.example.com/bananas/23/coconuts",
Link: "http://www.example.com/coconuts/31"
--> 200
Toast uses the (unusual) HTTP verbs LINK and UNLINK in order to express the action of linking or unlinking existing resources. The above request will add Coconut#31 to the association Banana#coconuts.