Skip to content

Instantly share code, notes, and snippets.

@Val
Created January 19, 2016 14:28
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Val/d9d672e82555d95ef0e3 to your computer and use it in GitHub Desktop.
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/
# -*- 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 <val@zbla.net>, 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: 'railssuperhero@email.com'
}
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: 'railssuperhero@email.com',
password: 'password',
password_confirmation: 'password')
user_2 = User.create(email: 'railshero@email.com',
password: 'password',
password_confirmation: 'password')
user_3 = User.create(email: 'railsrookie@email.com',
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"
@Val
Copy link
Author

Val commented Jan 19, 2016

Tested with ruby 2.3.0
Use following Gemfile to get all dependencies:

source 'http://rubygems.org'

ruby '2.3.0'

gem 'rails',                 '4.2.5'
gem 'mysql2',                '>= 0.4.2'
gem 'rspec-rails',           '>= 3.4'
gem 'factory_girl_rails',    '>= 4.5'
gem 'devise',                '>= 3.5.3'
gem 'grape',                 '>= 0.14'
gem 'grape-entity',          '= 0.4.5'
gem 'grape-entity-matchers', '>= 1.0.1'
gem 'squeel',                '>= 1.2.3'
gem 'sass-rails',            '>= 5.0.4'
gem 'sqlite3',               '>= 1.3.11'
gem 'uglifier',              '>= 2.7.2'
gem 'coffee-rails',          '>= 4.1.1'
gem 'jquery-rails',          '>= 4.0.5'
gem 'turbolinks',            '>= 2.5.3'
gem 'jbuilder',              '>= 2.4'
gem 'sdoc',                  '>= 0.4.1'
gem 'web-console',           '~> 2.2.1'
gem 'spring',                '>= 1.6.2'
gem 'grape-swagger',         '>= 0.10.4'
gem 'grape-swagger-ui',      '>= 0.0.9'

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