-
-
Save codesoda/e78965f2d809d3c24ca8 to your computer and use it in GitHub Desktop.
Sinatra API Helpers
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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: "[email protected]") | |
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class User < ActiveRecord::Base | |
def self.prototype(overrides = {}) | |
attributes = { | |
name: "James Miller", | |
login: "bensie", | |
email: "[email protected]", | |
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 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment