Created
January 19, 2016 14:28
-
-
Save Val/d9d672e82555d95ef0e3 to your computer and use it in GitHub Desktop.
Token protected REST-like API using Grape, Swagger on top of Rails. Based on http://www.toptal.com/ruby/grape-gem-tutorial-how-to-build-a-rest-like-api-in-ruby/
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
# -*- mode:ruby;tab-width:2;indent-tabs-mode:nil;coding:utf-8 -*- | |
# vim: ft=ruby syn=ruby fileencoding=utf-8 sw=2 ts=2 ai eol et si | |
# | |
# build_rest-like_api.rb: sample REST-like API server | |
# (c) 2016 Laurent Vallar <[email protected]>, WTFPL license v2 see below. | |
# | |
# This program is free software. It comes without any warranty, to | |
# the extent permitted by applicable law. You can redistribute it | |
# and/or modify it under the terms of the Do What The Fuck You Want | |
# To Public License, Version 2, as published by Sam Hocevar. See | |
# http://www.wtfpl.net/ for more details. | |
# | |
# see: | |
# http://edgeguides.rubyonrails.org/rails_application_templates.html | |
# | |
# run with: | |
# rails new rest_api -m build_rest-like_api.rb --skip-test-unit | |
# | |
# based on following blog post: | |
# http://www.toptal.com/ruby/grape-gem-tutorial-how-to-build-a-rest-like-api-in-ruby/ | |
# define rails environment | |
RAILS_ENV = ENV['RAILS_ENV'] ||= 'development' | |
# configure Gemfile | |
gem 'rails', '4.2.5' | |
gem 'mysql2', '>= 0.4.2' | |
gem 'rspec-rails', '>= 3.4', { group: [:test, :development] } | |
gem 'factory_girl_rails', '>= 4.5', { group: [:test, :development] } | |
gem 'devise', '>= 3.5.3' | |
gem 'grape', '>= 0.14' | |
gem 'grape-entity', '= 0.4.5' | |
gem 'grape-entity-matchers', '>= 1.0.1', { group: [:test, :development] } | |
gem 'squeel', '>= 1.2.3' | |
gem 'grape-swagger', '>= 0.10.4' | |
gem 'grape-swagger-ui', '>= 0.0.9' | |
create_file('.ruby-version') { '2.3.0' } | |
inject_into_file('Gemfile', after: %r{^source 'https:/rubygems.org'$}) do | |
"\nruby '2.3.0'\n" | |
end | |
db_config = 'config/database.yml' | |
remove_file db_config | |
create_file db_config do | |
<<-YAML | |
default: &default | |
adapter: mysql2 | |
encoding: utf8 | |
username: nimp | |
password: nimp | |
socket: /var/run/mysqld/mysqld.sock | |
host: localhost | |
pool: 4 | |
timeout: 300 | |
encoding: utf8mb4 | |
development: | |
<<: *default | |
database: nimp_development | |
test: | |
<<: *default | |
database: nimp_test | |
production: | |
<<: *default | |
database: nimp_production | |
YAML | |
end | |
create_file 'config/initializers/mysql_uft8mb4_support.rb' do | |
<<-RUBY | |
require 'active_record/connection_adapters/abstract_mysql_adapter' | |
# Set default mysql string column length to 191 instead of 255 which is the new | |
# index limit on utf8mb4 (aka real utf8). | |
module ActiveRecord | |
module ConnectionAdapters | |
class AbstractMysqlAdapter | |
NATIVE_DATABASE_TYPES[:string] = { :name => "varchar", :limit => 191 } | |
end | |
end | |
end | |
RUBY | |
end | |
rake :'db:drop', env: RAILS_ENV | |
rake :'db:create', env: RAILS_ENV | |
rake :'db:migrate', env: RAILS_ENV | |
generate :'rspec:install' | |
generate :model, 'User' | |
rake :'db:migrate', env: RAILS_ENV | |
generate :'devise:install' | |
generate :devise, 'User' | |
rake :'db:migrate', env: RAILS_ENV | |
generate(:model, | |
'AuthenticationToken', | |
'token:string', | |
'user:references', | |
'expires_at:datetime') | |
generate(:model, | |
'AuditLog', | |
'backtrace:string', | |
'data:string', | |
'user:references') | |
generate :model, 'Project', 'name:string' | |
generate(:model, | |
'PairProgrammingSession', | |
'project:references', | |
'host_user:references', | |
'visitor_user:references') | |
generate(:model, | |
'Review', | |
'pair_programming_session:references', | |
'user:references', | |
'comment:string') | |
generate :model, 'ApiKey', 'token:string' | |
create_pair_programming_sessions_file = | |
Dir.glob('db/migrate/*_create_pair_programming_sessions.rb').first | |
remove_file create_pair_programming_sessions_file | |
create_file create_pair_programming_sessions_file do | |
<<-RUBY | |
class CreatePairProgrammingSessions < ActiveRecord::Migration | |
def change | |
create_table :pair_programming_sessions do |t| | |
t.references :project, index: true, foreign_key: true | |
t.references :host_user, index: true | |
t.references :visitor_user, index: true | |
t.timestamps null: false | |
end | |
add_foreign_key :pair_programming_sessions, :users, column: :host_user_id | |
add_foreign_key :pair_programming_sessions, :users, column: :visitor_user_id | |
end | |
end | |
RUBY | |
end | |
generate :model, 'CodeSample', 'review:references', 'code:text' | |
inject_into_file('app/models/user.rb', | |
after: /^class User < ActiveRecord::Base$/) do | |
<<-RUBY | |
has_many :authentication_tokens | |
RUBY | |
end | |
authentication_token_file = 'app/models/authentication_token.rb' | |
remove_file authentication_token_file | |
create_file authentication_token_file do | |
<<-RUBY | |
require 'securerandom' | |
class AuthenticationToken < ActiveRecord::Base | |
belongs_to :user | |
validates :token, presence: true | |
scope :valid, -> do | |
where { (expires_at == nil) | (expires_at > Time.zone.now) } | |
end # squeel gem syntax | |
def self.generate(user) | |
require 'securerandom' | |
create! user: user, token: SecureRandom.hex | |
end | |
end | |
RUBY | |
end | |
rake :'db:migrate', env: RAILS_ENV | |
inject_into_file 'spec/rails_helper.rb', before: /^end$/ do | |
<<-RUBY | |
# Use the FactoryGirl abbreviated version of record creation in our specs | |
config.include FactoryGirl::Syntax::Methods | |
# Load and use shared examples as expectation | |
Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f } | |
RUBY | |
end | |
FileUtils.mkdir_p 'spec/support' | |
create_file 'spec/support/shared.rb' do | |
<<-RUBY | |
RSpec.shared_examples 'json result' do | |
specify 'returns JSON' do | |
api_call params | |
expect { JSON.parse(response.body) }.not_to raise_error | |
end | |
end | |
%w(200 400 401).each do |code| | |
RSpec.shared_examples code do | |
specify "returns \#{code}" do | |
api_call params, developer_header | |
expect(response.status).to eq(code.to_i) | |
end | |
end | |
end | |
RSpec.shared_examples 'restricted for developers' do | |
context 'without developer key' do | |
specify 'should be an unauthorized call' do | |
api_call params | |
expect(response.status).to eq(401) | |
end | |
specify 'error code is 1001' do | |
api_call params | |
json = JSON.parse(response.body) | |
expect(json['error_code']).to eq(ErrorCodes::DEVELOPER_KEY_MISSING) | |
end | |
end | |
end | |
RSpec.shared_examples 'unauthenticated' do | |
context 'unauthenticated' do | |
specify 'returns 401 without token' do | |
api_call params.except(:token), developer_header | |
expect(response.status).to eq(401) | |
end | |
specify 'returns JSON' do | |
api_call params.except(:token), developer_header | |
json = JSON.parse(response.body) | |
end | |
end | |
end | |
RSpec.shared_examples 'contains error code' do |code| | |
specify "error code is \#{code}" do | |
api_call params, developer_header | |
json = JSON.parse(response.body) | |
expect(json['error_code']).to eq(code) | |
end | |
end | |
RSpec.shared_examples 'contains error msg' do |msg| | |
specify "error msg is \#{msg}" do | |
api_call params, developer_header | |
json = JSON.parse(response.body) | |
expect(json['error_msg']).to eq(msg) | |
end | |
end | |
RSpec.shared_examples 'auditable created' do | |
specify 'creates an api call audit' do | |
expect do | |
api_call params, developer_header | |
end.to change{ AuditLog.count }.by(1) | |
end | |
end | |
RUBY | |
end | |
FileUtils.mkdir_p 'spec/api' | |
create_file 'spec/api/login_spec.rb' do | |
<<-RUBY | |
require 'rails_helper' | |
describe '/api/login' do | |
let(:email) { user.email } | |
let(:password) { user.password } | |
let!(:user) { create :user } | |
let(:original_params) { { email: email, password: password } } | |
let(:params) { original_params } | |
let(:api_key) { create :api_key } | |
let(:developer_header) { {'Authorization' => api_key.token} } | |
def api_call *params | |
post "/api/login", *params | |
end | |
context 'negative tests' do | |
context 'missing params' do | |
context 'password' do | |
let(:params) { original_params.except(:password) } | |
it_behaves_like '400' | |
it_behaves_like 'json result' | |
it_behaves_like 'contains error msg', 'password is missing' | |
end | |
context 'email' do | |
end | |
end | |
context 'invalid params' do | |
context 'incorrect password' do | |
let(:params) { original_params.merge(password: 'invalid') } | |
it_behaves_like '401' | |
it_behaves_like 'json result' | |
it_behaves_like 'contains error msg', 'Bad Authentication Parameters' | |
end | |
context 'with a non-existent login' do | |
end | |
end | |
end | |
context 'positive tests' do | |
context 'valid params' do | |
it_behaves_like '200' | |
it_behaves_like 'json result' | |
specify 'returns the token as part of the response' do | |
api_call params | |
expect(JSON.parse(response.body)['token']).to be_present | |
end | |
end | |
end | |
end | |
RUBY | |
end | |
user_factory = 'spec/factories/users.rb' | |
remove_file user_factory | |
create_file user_factory do | |
<<-RUBY | |
FactoryGirl.define do | |
factory :user do | |
password "Passw0rd" | |
password_confirmation { |u| u.password } | |
sequence(:email) { |n| "test\#{n}@example.com" } | |
end | |
end | |
RUBY | |
end | |
FileUtils.mkdir_p 'app/models/entities' | |
create_file 'app/models/entities/user_entity.rb' do | |
<<-RUBY | |
module Entities | |
class UserEntity < Grape::Entity | |
expose :email | |
end | |
end | |
RUBY | |
end | |
create_file 'app/models/entities/user_with_token_entity.rb' do | |
<<-RUBY | |
module Entities | |
class UserWithTokenEntity < UserEntity | |
expose :token do |user, options| | |
user.authentication_tokens.valid.first.token | |
end | |
end | |
end | |
RUBY | |
end | |
authentication_token_factory = 'spec/factories/authentication_tokens.rb' | |
remove_file authentication_token_factory | |
create_file authentication_token_factory do | |
<<-RUBY | |
FactoryGirl.define do | |
factory :authentication_token do | |
token "MyString" | |
expires_at 1.day.from_now | |
user | |
end | |
end | |
RUBY | |
end | |
FileUtils.mkdir_p 'spec/models/entities' | |
create_file 'spec/models/entities/user_with_token_entity_spec.rb' do | |
<<-RUBY | |
require 'rails_helper' | |
describe Entities::UserWithTokenEntity do | |
describe 'fields' do | |
subject(:subject) { Entities::UserWithTokenEntity } | |
specify { expect(subject).to represent(:email)} | |
let!(:token) { create :authentication_token } | |
specify 'presents the first available token' do | |
json = Entities::UserWithTokenEntity.new(token.user).as_json | |
expect(json[:token]).to be_present | |
end | |
end | |
end | |
RUBY | |
end | |
inject_into_file 'config/application.rb', before: /^ end$/ do | |
<<-RUBY | |
config.paths.add File.join('app', 'api'), glob: File.join('**', '*.rb') | |
config.autoload_paths += Dir[Rails.root.join('app', 'api', '*')] | |
RUBY | |
end | |
FileUtils.mkdir_p 'app/api' | |
create_file 'app/api/login.rb' do | |
<<-RUBY | |
class Login < Grape::API | |
content_type :json, 'application/json; charset=UTF-8' | |
format :json | |
rescue_from Grape::Exceptions::ValidationErrors do |e| | |
error!({ error_msg: e.message }, 400) | |
end | |
desc 'End-points for the Login' | |
namespace :login do | |
desc 'Login via email and password' | |
params do | |
requires :email, type: String, desc: 'email', documentation: { | |
example: '[email protected]' | |
} | |
requires :password, type: String, desc: 'password', documentation: { | |
example: 'password' | |
} | |
end | |
post do | |
user = User.find_by_email params[:email] | |
if user.present? && user.valid_password?(params[:password]) | |
token = user.authentication_tokens.valid.first || | |
AuthenticationToken.generate(user) | |
status 200 | |
present token.user, with: Entities::UserWithTokenEntity | |
else | |
error!({ 'error_msg' => 'Bad Authentication Parameters' }, 401) | |
end | |
end | |
end | |
end | |
RUBY | |
end | |
create_file 'spec/api/pair_programming_spec.rb' do | |
<<-RUBY | |
require 'rails_helper' | |
describe '/api' do | |
let(:api_key) { create :api_key } | |
let(:developer_header) { {'Authorization' => api_key.token} } | |
describe '/pair_programming_session' do | |
def api_call *params | |
get '/api/pair_programming_sessions', *params | |
end | |
let(:token) { create :authentication_token } | |
let(:original_params) { { token: token.token } } | |
let(:params) { original_params } | |
it_behaves_like 'restricted for developers' | |
it_behaves_like 'unauthenticated' | |
context 'invalid params' do | |
context 'incorrect token' do | |
let(:params) { original_params.merge(token: 'invalid') } | |
it_behaves_like '401' | |
it_behaves_like 'json result' | |
it_behaves_like 'auditable created' | |
it_behaves_like 'contains error msg', 'authentication_error' | |
it_behaves_like('contains error code', | |
ErrorCodes::BAD_AUTHENTICATION_PARAMS) | |
end | |
end | |
context 'valid params' do | |
it_behaves_like '200' | |
it_behaves_like 'json result' | |
end | |
end | |
end | |
RUBY | |
end | |
create_file 'app/models/error_codes.rb' do | |
<<-RUBY | |
module ErrorCodes | |
DEVELOPER_KEY_MISSING = 1001 | |
BAD_AUTHENTICATION_PARAMS = 1002 | |
end | |
RUBY | |
end | |
FileUtils.mkdir_p 'app/api/api_helpers' | |
create_file 'app/api/api_helpers/authentication_helper.rb' do | |
<<-RUBY | |
module ApiHelpers | |
module AuthenticationHelper | |
TOKEN_PARAM_NAME = :token | |
def token_value_from_request(token_param = TOKEN_PARAM_NAME) | |
params[token_param] | |
end | |
def current_user | |
token = AuthenticationToken.find_by_token(token_value_from_request) | |
return nil unless token.present? | |
@current_user ||= token.user | |
end | |
def signed_in? | |
!!current_user | |
end | |
def authenticate! | |
unless signed_in? | |
AuditLog.create data: 'unauthenticated user access' | |
error!({ error_msg: "authentication_error", | |
error_code: ErrorCodes::BAD_AUTHENTICATION_PARAMS }, 401) | |
end | |
end | |
def restrict_access_to_developers | |
header_token = headers['Authorization'] | |
key = ApiKey.where { token == my { header_token } } | |
Rails.logger | |
.info "API call: \#{headers}\\tWith params: \#{params.inspect}" if | |
ENV['DEBUG'] | |
if key.blank? | |
error_code = ErrorCodes::DEVELOPER_KEY_MISSING | |
error_msg = 'please aquire a developer key' | |
error!({ :error_msg => error_msg, :error_code => error_code }, 401) | |
LogAudit.new({env: env}).execute | |
end | |
end | |
end | |
end | |
RUBY | |
end | |
create_file 'app/api/pair_programming_sessions.rb' do | |
<<-RUBY | |
class PairProgrammingSessions < Grape::API | |
helpers ApiHelpers::AuthenticationHelper | |
before { restrict_access_to_developers } | |
before { authenticate! } | |
format :json | |
desc 'End-points for the PairProgrammingSessions' | |
namespace :pair_programming_sessions do | |
desc 'Retrieve the pairprogramming sessions', { | |
headers: { | |
'Authorization' => { | |
description: 'valid API_KEY', | |
required: false, | |
default: '12345654321', | |
} | |
} | |
} | |
params do | |
requires :token, type: String, desc: 'token' | |
end | |
get do | |
sessions = PairProgrammingSession.where { | |
(host_user == my{current_user}) | (visitor_user == my{current_user}) | |
} | |
sessions = sessions.includes(:project, | |
:host_user, | |
:visitor_user, | |
reviews: [:code_samples, :user]) | |
present sessions, with: Entities::PairProgrammingSessionsEntity | |
end | |
end | |
end | |
RUBY | |
end | |
review_file = 'app/models/review.rb' | |
remove_file review_file | |
create_file review_file do | |
<<-RUBY | |
class Review < ActiveRecord::Base | |
belongs_to :pair_programming_session | |
belongs_to :user | |
has_many :code_samples | |
end | |
RUBY | |
end | |
pair_programming_session_file = 'app/models/pair_programming_session.rb' | |
remove_file pair_programming_session_file | |
create_file pair_programming_session_file do | |
<<-RUBY | |
class PairProgrammingSession < ActiveRecord::Base | |
belongs_to :project | |
belongs_to :host_user, class_name: :User | |
belongs_to :visitor_user, class_name: 'User' | |
has_many :reviews | |
end | |
RUBY | |
end | |
create_file 'app/models/entities/code_sample_entity.rb' do | |
<<-RUBY | |
module Entities | |
class CodeSampleEntity < Grape::Entity | |
expose :code | |
end | |
end | |
RUBY | |
end | |
create_file 'app/models/entities/review_entity.rb' do | |
<<-RUBY | |
module Entities | |
class ReviewEntity < Grape::Entity | |
expose :user, using: UserEntity | |
expose :code_samples, using: CodeSampleEntity | |
end | |
end | |
RUBY | |
end | |
create_file 'app/models/entities/project_entity.rb' do | |
<<-RUBY | |
module Entities | |
class ProjectEntity < Grape::Entity | |
expose :name | |
end | |
end | |
RUBY | |
end | |
create_file 'app/models/entities/pair_programming_sessions_entity.rb' do | |
<<-RUBY | |
module Entities | |
class PairProgrammingSessionsEntity < Grape::Entity | |
expose :project, using: ProjectEntity | |
expose :host_user, using: UserEntity | |
expose :visitor_user, using: UserEntity | |
expose :reviews, using: ReviewEntity | |
end | |
end | |
RUBY | |
end | |
create_file 'app/api/api.rb' do | |
<<-RUBY | |
class API < Grape::API | |
prefix 'api' | |
mount Login | |
mount PairProgrammingSessions | |
rescue_from Grape::Exceptions::ValidationErrors do |e| | |
rack_response({ status: e.status, error: e.message }, 400) | |
end | |
add_swagger_documentation | |
end | |
RUBY | |
end | |
route %Q(mount API => '/') | |
append_to_file 'db/seeds.rb' do | |
<<-RUBY | |
user_1 = User.create(email: '[email protected]', | |
password: 'password', | |
password_confirmation: 'password') | |
user_2 = User.create(email: '[email protected]', | |
password: 'password', | |
password_confirmation: 'password') | |
user_3 = User.create(email: '[email protected]', | |
password: 'password', | |
password_confirmation: 'password') | |
ApiKey.create token: '12345654321' | |
project_1 = Project.create name: 'Time Sheets' | |
project_2 = Project.create name: 'Toptal Blog' | |
project_3 = Project.create name: 'Hobby Project' | |
session_1 = PairProgrammingSession.create(project: project_1, | |
host_user: user_1, | |
visitor_user: user_2) | |
session_2 = PairProgrammingSession.create(project: project_2, | |
host_user: user_1, | |
visitor_user: user_3) | |
session_3 = PairProgrammingSession.create(project: project_3, | |
host_user: user_2, | |
visitor_user: user_3) | |
review_1 = session_1.reviews.create(user: user_1, | |
comment: 'Please DRY a bit your code') | |
review_2 = session_1.reviews.create(user: user_1, | |
comment: 'Please DRY a bit your specs') | |
review_3 = session_2.reviews.create(user: user_1, | |
comment: 'Please DRY your view templates') | |
review_4 = session_2.reviews.create(user: user_1, | |
comment: 'Please clean your N+1 queries') | |
review_1.code_samples.create code: 'Lorem Ipsum' | |
review_1.code_samples | |
.create(code: 'Do not abuse the single responsibility principle') | |
review_2.code_samples.create code: 'Use some shared examples' | |
review_2.code_samples.create code: 'Use at the beginning of specs' | |
RUBY | |
end | |
append_to_file 'config/initializers/assets.rb' do | |
<<-RUBY | |
Rails.application.config.assets.precompile += %w( swagger_ui.js ) | |
Rails.application.config.assets.precompile += %w( swagger_ui.css ) | |
RUBY | |
end | |
route %Q(root :to => redirect('/api/swagger')) | |
rake :'db:reset', env: RAILS_ENV | |
run "RAILS_ENV=#{RAILS_ENV} bundle exec rspec" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Tested with ruby 2.3.0
Use following
Gemfile
to get all dependencies: