Skip to content

Instantly share code, notes, and snippets.

@bensie
Created December 6, 2012 17:53
Show Gist options
  • Star 61 You must be signed in to star a gist
  • Fork 10 You must be signed in to fork a gist
  • Save bensie/4226520 to your computer and use it in GitHub Desktop.
Save bensie/4226520 to your computer and use it in GitHub Desktop.
Sinatra API Helpers
require "sinatra/base"
require "sinatra/namespace"
require "multi_json"
require "api/authentication"
require "api/error_handling"
require "api/pagination"
module Api
class Base < ::Sinatra::Base
register ::Sinatra::Namespace
register ::Sinatra::ErrorHandling
register ::Sinatra::Authentication
register ::Sinatra::Pagination
# We want JSON all the time, use our custom error handlers
set :show_exceptions, false
# Run the following before every API request
before do
content_type :json
permit_authentication
end
# Global helper methods available to all namespaces
helpers do
# Shortcut to generate json from hash, make it look good
def json(json)
MultiJson.dump(json, pretty: true)
end
# Parse the request body and enforce that it is a JSON hash
def parsed_request_body
if request.content_type.include?("multipart/form-data;")
parsed = params
else
parsed = MultiJson.load(request.body, symbolize_keys: true)
end
halt_with_400_bad_request("The request body you provide must be a JSON hash") unless parsed.is_a?(Hash)
return parsed
end
end
end
end
module Api
class Endpoints < Base
get "/" do
json({ message: "Welcome to the API" })
end
get "/v1" do
json({ message: "This is version 1 of the API" })
end
namespace "/v1" do
get "/me" do
authenticate!
json current_user.api_authenticated_hash
end
end
# Any unmatched request within the /api/ namespace should render 404 as JSON
# Stop the request here so that JSON gets returned instead of having it
# run through the whole Rails stack and spit HTML.
get "/*" do
halt_with_404_not_found
end
post "/*" do
halt_with_404_not_found
end
put "/*" do
halt_with_404_not_found
end
patch "/*" do
halt_with_404_not_found
end
delete "/*" do
halt_with_404_not_found
end
end
end
require "sinatra/base"
module Sinatra
module ErrorHandling
module Helpers
def halt_with_400_bad_request(message = nil)
message ||= "Bad request"
halt 400, json({ message: message })
end
def halt_with_401_authorization_required(message = nil, realm = "App Name")
message ||= "Authorization required"
headers 'WWW-Authenticate' => %(Basic realm="#{realm}")
halt 401, json({ message: message })
end
def halt_with_403_forbidden_error(message = nil)
message ||= "Forbidden"
halt 403, json({ message: message })
end
def halt_with_404_not_found
halt 404, json({ message: "Not found" })
end
def halt_with_422_unprocessible_entity
errors = []
resource = env['sinatra.error'].record.class.to_s
env['sinatra.error'].record.errors.each do |attribute, message|
code = case message
when "can't be blank"
"missing_field"
when "has already been taken"
"already_exists"
else
"invalid"
end
errors << {
resource: resource,
field: attribute,
code: code
}
end
halt 422, json({
message: "Validation failed",
errors: errors
})
end
def halt_with_500_internal_server_error
halt 500, json({
message: Rails.env.production? ? "Internal server error: this is a problem on our end and we've been notified of the issue" : env['sinatra.error'].message
})
end
end
def self.registered(app)
app.helpers ErrorHandling::Helpers
app.error ActiveRecord::RecordNotFound do
halt_with_404_not_found
end
app.error ActiveRecord::RecordInvalid do
halt_with_422_unprocessible_entity
end
app.error ActiveRecord::UnknownAttributeError do
halt_with_422_unprocessible_entity
end
app.error ActiveRecord::DeleteRestrictionError do
halt_with_400_bad_request
end
app.error MultiJson::DecodeError do
halt_with_400_bad_request("Problems parsing JSON")
end
app.error do
if ::Exceptional::Config.should_send_to_api?
::Exceptional::Remote.error(::Exceptional::ExceptionData.new(env['sinatra.error']))
end
halt_with_500_internal_server_error
end
end
end
register ErrorHandling
end
require 'multi_json'
module ApiMacros
def json(content)
MultiJson.dump(content, pretty: true)
end
def decode_json(content)
MultiJson.load(content, symbolize_keys: true)
end
def authorize(user)
browser.authorize(user.email, "foobar")
end
def should_be_json
browser.last_response.headers["Content-Type"].should == "application/json;charset=utf-8"
end
def should_200(payload = nil)
browser.last_response.body.should == json(payload)
browser.last_response.status.should == 200
end
def should_201
browser.last_response.status.should == 201
end
def should_204
browser.last_response.status.should == 204
browser.last_response.body.should == ""
browser.last_response.headers["Content-Type"].should == nil
end
def should_400(message = nil)
browser.last_response.body.should == json({message: message || "Bad request"})
browser.last_response.status.should == 400
end
def should_401(payload = {message: "Authorization required"})
browser.last_response.body.should == json(payload)
browser.last_response.status.should == 401
end
def should_403(message = nil)
browser.last_response.body.should == json({message: message || "Forbidden"})
browser.last_response.status.should == 403
end
def should_404
browser.last_response.body.should == json({message: "Not found"})
browser.last_response.status.should == 404
end
def should_422
browser.last_response.status.should == 422
end
end
require "sinatra/base"
module Sinatra
module Pagination
module Helpers
def paginate(relation)
@paginated = relation.paginate(page: page, per_page: per_page)
add_pagination_headers
return @paginated
end
private
def add_pagination_headers
request_url = request.url.split("?")[0]
links = []
links << %(<#{request_url}?page=#{@paginated.previous_page.to_s}&per_page=#{per_page}>; rel="prev") if @paginated.previous_page
links << %(<#{request_url}?page=#{@paginated.next_page.to_s}&per_page=#{per_page}>; rel="next") if @paginated.next_page
links << %(<#{request_url}?page=1&per_page=#{per_page}>; rel="first")
links << %(<#{request_url}?page=#{@paginated.total_pages.to_s}&per_page=#{per_page}>; rel="last")
headers "Link" => links.join(",")
end
# Ensure that invalid page numbers just return the first page
# An out of range page number is still valid -- 0, -1, foo are not valid
def page
p = params[:page].to_i
p.between?(1, Float::INFINITY) ? p : 1
end
# Default to 30 items per page
# Permit up to 200 items per page, if more than 200 are requested, return 200
def per_page
max = 200
if per = params[:per_page].to_i
if per.between?(1, max)
per
elsif per > max
max
elsif per < 1
30
end
else
30
end
end
end
def self.registered(app)
app.helpers Pagination::Helpers
end
end
register Pagination
end
require 'spec_helper'
require 'rack/test'
describe Api::Endpoints do
let(:browser) { Rack::Test::Session.new(Rack::MockSession.new(Api::Endpoints, "myapp.dev")) }
describe "base" do
it "responds with json at the root" do
browser.get("/")
should_200({message: "Welcome to the API"})
should_be_json
end
it "responds with 404 json at misc not found paths" do
browser.get("/a")
should_404
should_be_json
browser.get("/a-b")
should_404
should_be_json
browser.get("/a/b/c")
should_404
should_be_json
end
end
describe "users" do
before do
@user = User.create! User.prototype
@rico = User.create! User.prototype(login: "rico", email: "rico@gmail.com")
authorize(@user)
end
it "gets the authenticated user" do
browser.get("/v1/me")
lrb = decode_json(browser.last_response.body)
lrb.should == @user.api_authenticated_hash
should_be_json
end
it "gets the authenticated user when specifying the username" do
browser.get("/v1/users/bensie")
lrb = decode_json(browser.last_response.body)
lrb.should == @user.api_authenticated_hash
should_be_json
end
it "gets another user when specifying the username" do
browser.get("/v1/users/rico")
lrb = decode_json(browser.last_response.body)
lrb.should == @rico.api_full_hash
should_be_json
end
end
describe "events" do
before do
@user = User.create! User.prototype
@event = @user.events.create! Event.prototype
end
it "should fetch a collection of events" do
browser.get("/v1/users/#{@user.login}/events")
should_200([@event.api_base_hash])
end
end
end
class User < ActiveRecord::Base
def self.prototype(overrides = {})
attributes = {
name: "James Miller",
login: "bensie",
email: "bensie@gmail.com",
password: "foobar",
}
attributes.merge(overrides)
end
def api_base_hash
{
id: id,
login: login,
name: name,
email: email,
api_url: "https://myapp.com/api/v1/users/#{login}",
html_url: "https://myapp.com/#{login}",
created_at: created_at.utc.iso8601
}
end
def api_full_hash
api_base_hash.merge({
updated_at: updated_at.utc.iso8601
})
end
def api_authenticated_hash
api_full_hash.merge({
plan: plan,
cc_last4: cc_last4
})
end
end
@NARKOZ
Copy link

NARKOZ commented Dec 12, 2012

with Sinatra::MultiRoute

route :get, :post, :put, :patch, :delete, '/*' do
  halt_with_404_not_found
end

Also I think API should return 405: method not allowed.

@bensie
Copy link
Author

bensie commented Dec 13, 2012

Not worth another dependency when it just saves a few lines of code, though that could be very useful if you want to support both PUT and PATCH verbs or something for a given endpoint.

Where should it return 405? I'm totally open to that -- though to me a 405 would make sense for an endpoint that does exist but doesn't respond to the requested method. 404 for a completely nonexistent endpoint.

@oogali
Copy link

oogali commented Nov 18, 2013

External dependencies suck.

[ :get, :post, :put, :patch, :delete ].each do |method|
  __send__ method, '/*' do
    halt_with_404_not_found
  end
end

@radiospiel
Copy link

The symbolization of the keys opens the door to DOS attacks in MultiJson.load(request.body, symbolize_keys: true)

@lksv
Copy link

lksv commented Jul 10, 2015

nice gist, thanks for sharing. Unfortunately I cannot use it without a license.

...this is generally problem of all gists and code on stackoverwflow.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment