title: Building Web Services (HTTP APIs) with Ruby (and Sinatra)
%css
pre { padding: 4px 4px 4px 4px; border-top: #bbb 1px solid; border-bottom: #bbb 1px solid; background: #f3f3f3; }
%end
- What's Sinatra?
- Let a Thousand Sinatra Clones Bloom
- Why Sinatra? Goodies
- Example Web Service (HTTP JSON API) - Routes
- Sinatra in Action -
get '/beer/:key'
- What's JSON?
- What's JSONP? What's CORS?
- Serializers - From Ruby Objects to JavaScript Objects
- What's Rack?
- Rum, Cuba, Roda - More Micro Webframework Alternatives
- What's Metal? Rack v2.0 - Faster, Faster, Faster
- HTTP JSON API - Faster, Faster, Faster
- Appendix: Sinatra Books
- Appendix: "Real World" HTTP JSON APIs
- Appendix: JSON Schema
- Appendix: HTTP JSON API Guidlines
Simple (yet powerful and flexible) micro webframework.
require 'sinatra'
get '/' do
'Hallo Linz! Hallo Pasching!'
end
Sinatra itself less than 2000 lines of code.
Installation. Type in your terminal (shell):
$ gem install sinatra
Example - hallo.rb
:
require 'sinatra'
get '/' do
'Hallo Linz! Hallo Pasching!'
end
Run script (server):
$ ruby hallo.rb
>> Sinatra has taken the stage...
>> Listening on 0.0.0.0:4567, CTRL+C to stop
Open browser:
Many micro frameworks inspired by Sinatra. Example:
Express.js (in Server-Side JavaScript w/ Node.js):
var express = require( 'express' );
var app = express();
app.get( '/', function( req, res ) {
res.send( 'Hallo Linz! Hallo Pasching!' );
});
app.listen( 4567 );
Scotty (in Haskell):
What's Haskell?
- Haskell is an advanced purely-functional programming language e.g. a monad is just a monoid in the category of endofunctors, what's the problem?
import Web.Scotty
main :: IO ()
main = scotty 4567 $ do
get "/" $ text "Hallo Linz! Hallo Pasching!"
- Perl: Dancer
- PHP: Fitzgerald
- Groovy: Ratpack
- CoffeeScript: Zappa
- Lua: Mercury
- F#/.NET: Frank
- C#/.NET: Nancy
- C: Bogart
- Go: Martini
- Python: Flask (started as an april fool's joke - that easy?! - it can't be true)
- and many more.
-
Single file scripts
-
Easy to package up into a gem. Example:
$ gem install beerdb # Yes, the beerdb gem includes a Sinatra app.
-
Lets you build command line tools. Example:
$ beerdb serve # Startup web service (HTTP JSON API).
-
Lets you mount app inside app (including Rails). Example:
mount BeerDb::Service, at: '/api/v3'
Lets build a beer and brewery API.
Get beer by key /beer/:key
. Examples:
/beer/guinness
/beer/murphysred
/beer/zipferurtyp
/beer/hofstettnergranitbock
Get brewery by key /brewery/:key
. Examples:
/brewery/guinness
/brewery/murphy
/brewery/zipf
/brewery/hofstetten
Bonus:
Get random beer /beer/rand
and random brewery /brewery/rand
.
beerdb/server.rb
:
include BeerDb::Models
get '/beer/:key' do |key|
beer = Beer.find_by!( key: key )
json_or_jsonp( beer.as_json )
end
get '/brewery/:key' do |key|
brewery = Brewery.find_by!( key: key )
json_or_jsonp( brewery.as_json )
end
That's it.
get '/beer/:key' do |key|
if ['r', 'rnd', 'rand', 'random'].include?( key )
beer = Beer.rnd.first
else
beer = Beer.find_by!( key: key )
end
json_or_jsonp( beer.as_json )
end
get '/brewery/:key' do |key|
if ['r', 'rnd', 'rand', 'random'].include?( key )
brewery = Brewery.rnd.first
else
brewery = Brewery.find_by!( key: key )
end
json_or_jsonp( brewery.as_json )
end
JSON = JavaScript Object Notation
Example - GET /beer/hofstettnergranitbock
:
{
"key": "hofstettnergranitbock",
"title": "Hofstettner Granitbock",
"abv": 7.2,
"og": 17.8,
"tags": [ "lager", "bock" ],
"brewery": {
"key": "hofstetten",
"title": "Brauerei Hofstetten"
},
"country": {
"key": "at",
"title": "Austria"
}
}
JSONP / JSON-P = JSON with Padding. Why?
Call Home Restriction. Cross-Domain Browser Requests Get Blocked.
Hack: Wrap JSON into a JavaScript function/callback
e.g. functionCallback( <json_data_here> )
and serve as plain old JavaScript.
Example - Content-Type: application/json
:
{
"key": "hofstettnergranitbock",
"title": "Hofstettner Granitbock",
"abv": "7.2",
...
}
becomes Content-Type: application/javascript
:
functionCallback(
{
"key": "hofstettnergranitbock",
"title": "Hofstettner Granitbock",
"abv": 7.2,
...
}
);
Bonus: Little Sinatra helper for JSON or JSONP response (depending on callback parameter).
def json_or_jsonp( json )
callback = params.delete('callback')
if callback
content_type :js
response = "#{callback}(#{json})"
else
content_type :json
response = json
end
end
CORS = Cross-Origin Resource Sharing
JSONP "Hack" no longer needed; let's you use "plain" standard HTTP requests in JavaScript; requires a "modern" browser
Uses HTTP headers in request and response (for access control).
Browser must send along Origin header in request:
Origin: http://foo.example
Server must return Access-Control-Allow-Origin header in request to allow access to browser:
Access-Control-Allow-Origin: *
CORS HTTP Request Headers:
Origin: http://example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: X-PINGOTHER
CORS HTTP Response Headers:
Access-Control-Allow-Origin: http://example.com
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: X-PINGOTHER
Access-Control-Max-Age: 1728000
JSON built into Ruby 2.x as a standard library. Example:
require 'json'
hash =
{
key: "hofstettnergranitbock",
title: "Hofstettner Granitbock"
}
puts JSON.generate( hash )
# => {"key":"hofstettnergranitbock","title":"Hofstettner Granitbock"}
puts hash.to_json
# => {"key":"hofstettnergranitbock","title":"Hofstettner Granitbock"}
Serializers for your models. Example:
class BeerSerializer
def initialize( beer )
@beer = beer
end
attr_reader :beer
def as_json
data = { key: beer.key,
title: beer.title,
abv: beer.abv,
...
}
data.to_json
end
end # class BeerSerializer
And add as_json
to your Model. Example:
class Beer < ActiveRecord::Base
def as_json_v2( opts={} )
BeerSerializer.new( self ).as_json
end
end # class Beer
rails/jbuilder
- Create JSON structures via a builder-style DSL
json.content format_content(@message.content)
json.(@message, :created_at, :updated_at)
json.author do
json.name @message.creator.name.familiar
json.email_address @message.creator.email_address_with_name
json.url url_for(@message.creator, format: :json)
end
json.comments @message.comments, :content, :created_at
json.attachments @message.attachments do |attachment|
json.filename attachment.filename
json.url url_for(attachment)
end
will create
{
"content": "<p>This is <i>serious</i> monkey business</p>",
"created_at": "2011-10-29T20:45:28-05:00",
"updated_at": "2011-10-29T20:45:28-05:00",
"author": {
"name": "David H.",
"email_address": "'David Heinemeier Hansson' <david@heinemeierhansson.com>",
"url": "http://example.com/users/1-david.json"
},
"comments": [
{ "content": "Hello everyone!", "created_at": "2011-10-29T20:45:28-05:00" },
{ "content": "To you my good sir!", "created_at": "2011-10-29T20:47:28-05:00" }
],
"attachments": [
{ "filename": "forecast.xls", "url": "http://example.com/downloads/forecast.xls" },
{ "filename": "presentation.pdf", "url": "http://example.com/downloads/presentation.pdf" }
]
}
rubys/wunderbar
- Another builder-style DSL
Wunderbar.json do
_content format_content(@message.content)
_ @message, :created_at, :updated_at
_author do
_name @message.creator.name.familiar
_email_address @message.creator.email_address_with_name
_url url_for(@message.creator, format: :json)
end
_comments @message.comments, :content, :created_at
_attachments @message.attachments do |attachment|
_filename attachment.filename
_url url_for(attachment)
end
end
Lets you mix 'n' match servers and apps.
Lets you stack apps inside apps inside apps inside apps inside apps.
Good News: A Sinatra app is a Rack app.
Learn more about Rack @ rack.github.io
.
Mimimalistic Rack App
lambda { |env| [200, {'Content-Type' => 'text/plain'}, ['Hello Linz! Hello Pasching!']] }
To use Rack, provide an "app": an object that responds to the call method, taking the environment hash as a parameter, and returning an Array with three elements:
- The HTTP response code e.g.
200
- A Hash of headers e.g.
{ 'Content-Type' => 'text/plain' }
- The response body, which must respond to each e.g.
["Hello Linz! Hello Pasching!"]
hello.rb
:
require 'rack'
hello_app = ->{ |env|
[ 200,
{ 'Content-Type' => 'text/plain' },
[ 'Hello Linz! Hello Pasching!' ]
]
}
Rack::Handler::Webserver.run( hello_app )
To be done
sport.db.admin/config/routes.rb
:
Sportdbhost::Application.routes.draw do
mount About::App, at: '/sysinfo'
mount DbBrowser::App, at: '/browse'
get '/api' => redirect('/api/v1')
mount SportDb::Service, at: '/api/v1'
mount LogDb::App, at: '/logs'
mount SportDbAdmin::Engine, at: '/'
end
Rum - gRand Unified Mapper for Rack apps
First version by Rack inventor Christian Neukirchen in 2008. Example:
App = Rum.new {
on get, path('greet') do
on param("name") do |name|
puts "Hello, #{Rack::Utils.escape_html name}!"
end
on default do
puts "Hello Linz! Hello Pasching!"
end
end
}
Cuba - Tiny but powerful Mapper for Rack apps
Uses the idea of Rum (thus, the name Cuba) and adds a little more machinery. Example:
on get, "articles/:id" do |id|
article = Article[id]
on "comments/:id" do |id|
comment = article.comments[id]
end
end
Roda - Routing Tree Webframework
Uses the idea of Cuba and adds yet more machinery (e.g. better request tree, plugins, etc.). Example:
route do |r|
# GET / request
r.root do
r.redirect "/hello"
end
# /hello branch
r.on "hello" do
# GET /hello/world request
r.get "world" do
"Hello world!"
end
# /hello request
r.is do
# GET /hello request
r.get do
"Hello!"
end
# POST /hello request
r.post do
puts "Someone said hello!"
r.redirect
end
end
end
end
Why? Why? Why?
Assumption less lines of code => faster code, more requests/secs - only use what you need
Library | Lines of Code (LOC) |
---|---|
Cuba | 152 |
Sinatra | 1_476 |
Rails (*) | 13_181 |
(Almost) Sinatra | 7 |
(*) only ActionPack (Rails is over 40_000+ LOC)
(Source: Cuba Slides, (Almost) Sinatra)
Collected on:
- OSX, 10.8.5, MacBook Pro i5 (2.5GHz), 8GiG 1600 MHz DDR3.
- ruby 2.1.2p95 (GCC 4.7.3)
Library | Requests/sec | % from best |
---|---|---|
rack | 8808.83 | 100.0 % |
roda | 7182.15 | 81.53 % |
rack-response | 6876.73 | 78.07 % |
cuba | 6159.57 | 69.92 % |
sinatra | 2899.94 | 32.92 % |
Library | Allocs/Req | Memsize/Req |
---|---|---|
rack | 60 | 1704 |
roda | 71 | 2144 |
rack-response | 83 | 3072 |
cuba | 100 | 3457 |
sinatra | 253 | 10011 |
(Source: luislavena/bench-micro
)
Notes
$ wrk -t 2 http://localhost:9292/
All the micro frameworks were run using Puma on Ruby 2.1,
in production mode and using 16 threads:
$ puma -e production -t 16:16 apps/(rack.ru|rodo.ru|rack-response.ru|cuba.ru|sinatra.ru)
sinatra.ru
:
require "sinatra/base"
class HelloWorld < Sinatra::Base
get "/" do
"Hello World!"
end
end
App = HelloWorld
run App
rack.ru
:
class HelloWorld
def call(env)
if env['REQUEST_METHOD'] == 'GET' && env['PATH_INFO'] == '/'
[
200,
{"Content-Type" => "text/html"},
["Hello World!"]
]
else
[
404,
{"Content-Type" => "text/html"},
[""]
]
end
end
end
App = HelloWorld.new
run App
rack-response.ru
:
class HelloWorld
def call(env)
if env['REQUEST_METHOD'] == 'GET' && env['PATH_INFO'] == '/'
Rack::Response.new('Hello World!', 200, { 'Content-Type' => 'text/html' }).finish
else
Rack::Response.new('', 404, { 'Content-Type' => 'text/html' }).finish
end
end
end
App = HelloWorld.new
run App
Q: Why update (break) Rack v1.0?
A: Make it faster, faster, faster. Non-blocking streaming with asynchronous event callbacks is the new black.
An app without any middleware:
hello_app = ->(req, res) {
res.write_head( 200, 'Content-Type' => 'text/plain' )
res.write( "Hello Linz! Hello Pasching!\n" )
res.finish
}
require 'the_metal/puma'
server = TheMetal.create_server( hello_app )
server.listen( 9292, '0.0.0.0' )
You can use a class too:
class App
def call( req, res )
res.write_head( 200, 'Content-Type' => 'text/plain' )
res.write( "Hello Linz! Hello Pasching!\n" )
res.finish
end
end
require 'the_metal/puma'
server = TheMetal.create_server( App.new )
server.listen( 9292, '0.0.0.0' )
An app that checks out a database connection when the request starts and checks it back in when the response is finished:
require 'the_metal'
class App
def call( req, res )
res.write_head( 200, 'Content-Type' => 'text/plain' )
res.write( "Hello Linz! Hello Pasching!\n" )
res.finish
end
end
class DbEvents
def start_app( app )
puts "ensure database connection"
end
def start_request( req, res )
puts "-> checkout connection"
end
def finish_request( req, res )
puts "<- checkin connection"
end
end
require 'the_metal/puma'
app = TheMetal.build_app( [DbEvents.new], [], App.new )
server = TheMetal.create_server( app )
server.listen( 9292, '0.0.0.0' )
Trivia Quiz: What language is this?
func GetEvents() interface{} {
// step 1: fetch records
events := FetchEvents()
log.Println( events )
// step 2: map to json structs for serialization/marshalling
type JsEvent struct {
Key string `json:"key"`
Title string `json:"title"`
}
data := []*JsEvent{}
for _,event := range events {
data = append( data, &JsEvent {
Key: event.Key,
Title: event.Title, } )
}
return data
}
Just an example. Use what works for you. Why Go?
Kind of a "better" more "modern" C.
Code gets compiled (to machine-code ahead-of-time) and linked to let you build (small-ish) zero-dependency all-in-one binary (file) programs.
No virtual machine or byte code runtime or just-in-time compiler machinery needed; includes garbage collector.
Try a NoSQL database and get JSON HTTP APIs (almost) for "free".
Learn more about Sinatra @ sinatrarb.com
Learn more about the open beer 'n' brewery database (beer.db
) @ github.com/openbeer
require 'sinatra'
get '/' do
'Hallo Linz! Hallo Pasching!'
end
vs.
require 'sinatra/base'
class App < Sinatra::Base
get '/' do
'Hallo Linz! Hallo Pasching!'
end
end
Sinatra: Up and Running by Alan Harris, Konstantin Haase; November 2011, O'Reilly, 122 Pages
Jump Start Sinatra by Darren Jones; January 2013, SitePoint, 150 Pages
Grape - Micro framework for creating REST-like APIs
require 'grape'
class API < Grape::API
get :hello do
{ hello: "world" }
end
end
Crêpe - The thin API stack
require 'crepe'
class API < Crepe::API
get :hello do
{ hello: "world" }
end
end
Learn from the "masters". Examples:
- GitHub API -> developer.github.com/v3
- Basecamp API -> github.com/basecamp/api
- Heroku API -> devcenter.heroku.com/categories/platform-api
- Travis CI API -> docs.travis-ci.com/api
- Many more
$ curl -i https://api.github.com/users/octocat/orgs
Headers:
HTTP/1.1 200 OK
Server: nginx
Date: Fri, 12 Oct 2012 23:33:14 GMT
Content-Type: application/json; charset=utf-8
Connection: keep-alive
Status: 200 OK
ETag: "a00049ba79152d03380c34652f2cb612"
X-GitHub-Media-Type: github.v3
X-RateLimit-Limit: 5000
X-RateLimit-Remaining: 4987
X-RateLimit-Reset: 1350085394
Content-Length: 5
Cache-Control: max-age=0, private, must-revalidate
X-Content-Type-Options: nosniff
JSON Payload:
[]
GET /repos/:owner/:repo/commits
Headers:
Status: 200 OK
Link: <https://api.github.com/resource?page=2>; rel="next"
X-RateLimit-Limit: 5000
X-RateLimit-Remaining: 4999
JSON Payload:
[
{
"url": "https://api.github.com/repos/octocat/Hello-World/commits/6dcb09b5b57875f334f61aebed695e2e4193db5e",
"sha": "6dcb09b5b57875f334f61aebed695e2e4193db5e",
"html_url": "https://github.com/octocat/Hello-World/commit/6dcb09b5b57875f334f61aebed695e2e4193db5e",
"comments_url": "https://api.github.com/repos/octocat/Hello-World/commits/6dcb09b5b57875f334f61aebed695e2e4193db5e/comments",
"commit": {
"url": "https://api.github.com/repos/octocat/Hello-World/git/commits/6dcb09b5b57875f334f61aebed695e2e4193db5e",
"author": {
"name": "Monalisa Octocat",
"email": "support@github.com",
"date": "2011-04-14T16:00:49Z"
},
"committer": {
"name": "Monalisa Octocat",
"email": "support@github.com",
"date": "2011-04-14T16:00:49Z"
},
"message": "Fix all the bugs",
"tree": {
"url": "https://api.github.com/repos/octocat/Hello-World/tree/6dcb09b5b57875f334f61aebed695e2e4193db5e",
"sha": "6dcb09b5b57875f334f61aebed695e2e4193db5e"
},
"comment_count": 0
},
"author": {
"login": "octocat",
"id": 1,
"avatar_url": "https://github.com/images/error/octocat_happy.gif",
"gravatar_id": "somehexcode",
"url": "https://api.github.com/users/octocat",
"html_url": "https://github.com/octocat",
"followers_url": "https://api.github.com/users/octocat/followers",
"following_url": "https://api.github.com/users/octocat/following{/other_user}",
"gists_url": "https://api.github.com/users/octocat/gists{/gist_id}",
"starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/octocat/subscriptions",
"organizations_url": "https://api.github.com/users/octocat/orgs",
"repos_url": "https://api.github.com/users/octocat/repos",
"events_url": "https://api.github.com/users/octocat/events{/privacy}",
"received_events_url": "https://api.github.com/users/octocat/received_events",
"type": "User",
"site_admin": false
},
"committer": {
"login": "octocat",
"id": 1,
"avatar_url": "https://github.com/images/error/octocat_happy.gif",
"gravatar_id": "somehexcode",
"url": "https://api.github.com/users/octocat",
"html_url": "https://github.com/octocat",
"followers_url": "https://api.github.com/users/octocat/followers",
"following_url": "https://api.github.com/users/octocat/following{/other_user}",
"gists_url": "https://api.github.com/users/octocat/gists{/gist_id}",
"starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/octocat/subscriptions",
"organizations_url": "https://api.github.com/users/octocat/orgs",
"repos_url": "https://api.github.com/users/octocat/repos",
"events_url": "https://api.github.com/users/octocat/events{/privacy}",
"received_events_url": "https://api.github.com/users/octocat/received_events",
"type": "User",
"site_admin": false
},
"parents": [
{
"url": "https://api.github.com/repos/octocat/Hello-World/commits/6dcb09b5b57875f334f61aebed695e2e4193db5e",
"sha": "6dcb09b5b57875f334f61aebed695e2e4193db5e"
}
]
}
]
Project site -> json-schema.org
What's JSON Schema?
Describe your data structure 'n' types (schema) in JSON. Example:
{
"title": "Example Schema",
"type": "object",
"properties": {
"firstName": {
"type": "string"
},
"lastName": {
"type": "string"
},
"age": {
"description": "Age in years",
"type": "integer",
"minimum": 0
}
},
"required": ["firstName", "lastName"]
}
Why?
Pros:
- More tooling
- auto-generate docu
- auto-generate tests
- auto-generate (test) client libraries
- auto-generate validator (for required fields, types, etc.)
- More (re)use
- (re)use "common" schemas
Example: Heroku API Design Guidelines
- Require TLS
- Version with Accepts header
- Support caching with Etags
- Trace requests with Request-Ids
- Paginate with ranges
- Requests
- Return appropriate status codes
- Provide full resources where available
- Accept serialized JSON in request bodies
- Use consistent path formats
- Downcase paths and attributes
- Support non-id dereferencing for convenience
- Minimize path nesting
- Responses
- Provide resource (UU)IDs
- Provide standard timestamps
- Use UTC times formatted in ISO8601
- Nest foreign key relations
- Generate structured errors
- Show rate limit status
- Keep JSON minified in all responses
(Source: github.com/interagent/http-api-design
)
Note: Site also includes Sinatra starter templates and generators.
{json:api} Project - jsonapis.org
A(nother) standard for building APIs in JSON. Example:
{
"links": {
"posts.author": {
"href": "http://example.com/people/{posts.author}",
"type": "people"
},
"posts.comments": {
"href": "http://example.com/comments/{posts.comments}",
"type": "comments"
}
},
"posts": [{
"id": "1",
"title": "Rails is Omakase",
"links": {
"author": "9",
"comments": [ "5", "12", "17", "20" ]
}
}]
}
Article:Best Practices for Designing a Pragmatic RESTful API by Vinay Sahni
TL;DR
- An API is a user interface for a developer - so put some effort into making it pleasant
- Use RESTful URLs and actions
- Use SSL everywhere, no exceptions
- An API is only as good as its documentation - so have great documentation
- Version via the URL, not via headers
- Use query parameters for advanced filtering, sorting & searching
- Provide a way to limit which fields are returned from the API
- Return something useful from POST, PATCH & PUT requests
- HATEOAS isn't practical just yet
- Use JSON where possible, XML only if you have to
- You should use camelCase with JSON, but snake_case is 20% easier to read
- Pretty print by default & ensure gzip is supported
- Don't use response envelopes by default
- Consider using JSON for POST, PUT and PATCH request bodies
- Paginate using Link headers
- Provide a way to autoload related resource representations
- Provide a way to override the HTTP method
- Provide useful response headers for rate limiting
- Use token based authentication, transported over OAuth2 where delegation is needed
- Include response headers that facilitate caching
- Define a consumable error payload
- Effectively use HTTP Status codes
Books
RESTful Web APIs by Leonard Richardson, Mike Amundsen; September 2013; O'Reilly 408 Pages
- Examine API design strategies, including the collection pattern and pure hypermedia
- Understand how hypermedia ties representations together into a coherent API
- Discover how XMDP and ALPS profile formats can help you meet the Web API "semantic challenge"
- Learn close to two-dozen standardized hypermedia data formats
- Apply best practices for using HTTP in API implementations
- Create Web APIs with the JSON-LD standard and other the Linked Data approaches
- Understand the CoAP protocol for using REST in embedded systems
REST API Design Rulebook: Designing Consistent RESTful Web Service Interfaces by Mark Masse; October 2011; O'Reilly; 116 Pages
And many more
- Designing Hypermedia APIs by Steve Klabnik
- RESTful Web Services Cookbook - Solutions for Improving Scalability and Simplicity by Subbu Allamaraju; February 2010; O'Reilly (Yahoo Press)
- REST in Practice - Hypermedia and Systems Architecture by Jim Webber, Savas Parastatidis, Ian Robinson September 2010; O'Reilly
- CORS in Action: Creating and consuming cross-origin APIs by Monsur Hossain; October 2014; Manning
- Getting Started with OAuth 2.0 - Programming clients for secure web API authorization and authentication by Ryan Boyd; February 2012; O'Reilly
- And so on.