Learn how to build a modern API on Michael Hartl's Rails 5 tutorial
Note 1: If you are looking for the regular readme, it's here.
Note 2: You can contribute to this tutorial by opening an issue or even sending a pull request!
Note 3: With the API I built, I went on and created the same app in Ember.
I will show how you can extend your Rails app and build an API without changing a single line of code from your existing app. We will be using Michael Hartl's Rails tutorial (I actually started learning Rails and subsequently Ruby from that tutorial, I really owe a beer to that guy) which is a classic Rails app and extend it by building an API for the app.
Designing an API is not an easy process. Usually it's very difficult to know beforehand what the client will need. However we will make our best to support most clients needs:
By the way, there is a long discussion about what REST means. Is just JSONAPI as REST as Joy Fielding's defined it? Definitely not. However, it's more resty than regular JSON response, plus it has a wide support in terms of libraries.
Moving forward, let's add our first resource, let it be a user. But before adding the controller let's add the routes first:
#api namespace :api do namespace :v1 do resources :sessions, only: [:create, :show] resources :users, only: [:index, :create, :show, :update, :destroy] do post :activate, on: :collection resources :followers, only: [:index, :destroy] resources :followings, only: [:index, :destroy] do post :create, on: :member end resource :feed, only: [:show] end resources :microposts, only: [:index, :create, :show, :update, :destroy] end end
All REST routes for each record, only GET method for collections (Rails muddles up collection REST routes with element REST routes in the same controllers) and a couple custom routes.
As you can see we have many routes. The idea is that the tutorial will mostly touch and show you a couple of them and you will manage to understand and see the rest from the code inside the repo. I think extended tutorials are boring :). However, if you find something weird or you don't understand something you are always welcomed to open an issue and ask :)
Let's create the users API controller and add support for the GET method on a single record:
The first thing we need to do is to separate our API from the rest of the app. In order to do that we will create a new Controller under a different namespace. Given that it's good to have versioned API let's go and create our first controller under
app/controllers/api/v1/
class Api::V1::BaseController < ActionController::API end
As you can see we inherit from
ActionController::APIinstead of
ActionController::Base. The former cuts down some features not needed making it a bit faster and less memory hungry :)
Now let's add the
users#showaction:
class Api::V1::UsersController < Api::V1::BaseController def show user = User.find(params[:id])render jsonapi: user, serializer: Api::V1::UserSerializer
end end
One thing that I like building APIs in Rails is that controllers are super clean by default. We just request the user from the database and render it in JSON using AMS.
Let's add the user serializer under
app/serializers/api/v1/user_serializer.rb. We will use ActiveModelSerializers for the JSON serialization.
class Api::V1::UserSerializer < ActiveModel::Serializer attributes(*User.attribute_names.map(&:to_sym))has_many :followers, serializer: Api::V1::UserSerializer
has_many :followings, key: :followings, serializer: Api::V1::UserSerializer end
If we now request a single user it will also render all followers and followings (users that the user follows). Usually we don't want that but instead we probably want AMS to render only the url for the client to fetch the data asynchronously. Let's change that and also add a link for Microposts (more info you can find on
active_model_serializerswiki):
class Api::V1::UserSerializer < ActiveModel::Serializer attributes(*User.attribute_names.map(&:to_sym))has_many :microposts, serializer: Api::V1::MicropostSerializer do include_data(false) link(:related) {api_v1_microposts_path(user_id: object.id)} end
has_many :followers, serializer: Api::V1::UserSerializer do include_data(false) link(:related) {api_v1_user_followers_path(user_id: object.id)} end
has_many :followings, key: :followings, serializer: Api::V1::UserSerializer do include_data(false) link(:related) {api_v1_user_followings_path(user_id: object.id)} end end
There is one more thing that needs to be fixed. If a client asks for a user that does not exist in our database,
findwill raise a
ActiveRecord::RecordNotFoundexception and Rails will return a 500 error. But what we actually want here is to return a 404 error. We can catch the exception in the
Api::V1::BaseControllerand make Rails return 404. Just add in
Api::V1::BaseController:
rescue_from ActiveRecord::RecordNotFound, with: :not_founddef not_found return api_error(status: 404, errors: 'Not found') end
A "Not found" in the body section is enough since the client can figure out the error from the 404 status code.
Tip: Exceptions in Ruby are quite slow. A faster way is to request the user from the db using findby and render 404 if findby returned a nil.
Important! yuki24 opened an issue to clarify that "rescue_from is possibly one of the worst Rails patterns of all time". Please take a look in the issue for more information until we have something better :)
If we now send a request
api/v1/users/1we get the following json response:
{ "data": { "id": "1", "type": "users", "attributes": { "name": "Example User", "email": "[email protected]", "created-at": "2016-11-05T10:15:26Z", "updated-at": "2016-11-19T21:30:10Z", "password-digest": "$2a$10$or7HFYm/H07/uE79wDae3uXMmHOX3BvRKdgedPJ1SPceiMA40V25O", "remember-digest": null, "admin": true, "activation-digest": "$2a$10$X5IeDtGZPuZQEVQ.ZiUP4eUzfw9M9Pag/nR.0ONiXwAAp3w98iAuC", "activated": true, "activated-at": "2016-11-05T10:15:26.300Z", "reset-digest": null, "reset-sent-at": null, }, "relationships": { "microposts": { "links": { "related": "/api/v1/microposts?user_id=1" } }, "followers": { "links": { "related": "/api/v1/users/1/followers" } }, "followings": { "links": { "related": "/api/v1/users/1/followings" } } } } }
Of course we need to add Authentication and Authorization on our API but we will take a look on that later :)
Now let's add a method to retrieve all users. Rails names that method index, in terms of REST it's a GET method that acts on the
userscollection.
class Api::V1::UsersController < Api::V1::BaseController def index users = User.allrender jsonapi: users, each_serializer: Api::V1::UserSerializer,
end end
Pretty easy right?
For authentication, the Rails app by Michael uses a custom implementation. That shouldn't be a problem because we build an API and we need to re-implement the authentication endpoint anyway. In APIs we don't use cookies and we don't have sessions. Instead, when a user wants to sign in she sends an HTTP POST request with her username and password to our API (in our case it's the
sessionsendpoint) which sends back a token. This token is user's proof of who she is. In each API request, rails finds the user based on the token sent. If no user found with the received token, or no token is sent, the API should return a 401 (Unauthorized) error.
Let's add the token to the user.
First we add a callback that adds a token to every new user is created.
before_validation :ensure_tokendef ensure_token self.token = generate_hex(:token) unless token.present? end
def generate_hex(column) loop do hex = SecureRandom.hex break hex unless self.class.where(column => hex).any? end end
and exactly after that we create the migration:
class AddTokenToUsers < ActiveRecord::Migration[5.0] def up add_column :users, :token, :stringUser.find_each{|user| user.save!} change_column_null :users, :token, false
end
def down remove_column :users, :token, :string end end
and run
bundle exec rails db:migrate. Now every user, new and old, has a valid unique non-null token.
Then let's add the
sessionsendpoint:
class Api::V1::SessionsController < Api::V1::BaseController def create if @user render( jsonapi: @user, serializer: Api::V1::SessionSerializer, status: 201, include: [:user], scope: @user ) else return api_error(status: 401, errors: 'Wrong password or username') end endprivate def create_params normalized_params.permit(:email, :password) end
def load_resource @user = User.find_by( email: create_params[:email] )&.authenticate(create_params[:password]) end def normalized_params ActionController::Parameters.new( ActiveModelSerializers::Deserialization.jsonapi_parse(params) ) end
end
And the sessions serializer:
class Api::V1::SessionSerializer < Api::V1::BaseSerializer type :sessionattributes :email, :token, :user_id
has_one :user, serializer: Api::V1::UserSerializer do link(:self) {api_v1_user_path(object.id)} link(:related) {api_v1_user_path(object.id)}
object
end
def user object end
def user_id object.id end
def token object.token end
def email object.email end end
The client probably needs only user's id, email and token but it's good to return some more data for better optimization. We might save us from an extra request to the users endpoint :)
{ "data": { "id": "1", "type": "session", "attributes": { "email": "[email protected]", "token": "f42f5ccee3689209e7ca8e4f9bd830e2", "user-id": 1 }, "relationships": { "user": { "data": { "id": "1", "type": "users" }, "links": { "self": "/api/v1/users/1", "related": "/api/v1/users/1" } } } }, "included": [ { "id": "1", "type": "users", "attributes": { "name": "Example User", "email": "[email protected]", "created-at": "2016-11-05T10:15:26Z", "updated-at": "2016-11-19T21:30:10Z", "password-digest": "$2a$10$or7HFYm/H07/uE79wDae3uXMmHOX3BvRKdgedPJ1SPceiMA40V25O", "remember-digest": null, "admin": true, "activation-digest": "$2a$10$X5IeDtGZPuZQEVQ.ZiUP4eUzfw9M9Pag/nR.0ONiXwAAp3w98iAuC", "activated": true, "activated-at": "2016-11-05T10:15:26.300Z", "reset-digest": null, "reset-sent-at": null, "token": "f42f5ccee3689209e7ca8e4f9bd830e2", "microposts-count": 99, "followers-count": 37, "followings-count": 48, "following-state": false, "follower-state": false }, "relationships": { "microposts": { "links": { "related": "/api/v1/microposts?user_id=1" } }, "followers": { "links": { "related": "/api/v1/users/1/followers" } }, "followings": { "links": { "related": "/api/v1/users/1/followings" } } } } ] }
Tip: Yes we need to add proper authorization: return only the attributes that the client is allowed to see, we will deal with that a bit later :)
Once the client has the token it sends both token and email to the API for each subsequent request. Now let's add the
authenticate_user!filter inside the
Api::V1::BaseController:
def authenticate_user! token, options = ActionController::HttpAuthentication::Token.token_and_options( request )return nil unless token && options.is_a?(Hash) user = User.find_by(email: options['email']) if user && ActiveSupport::SecurityUtils.secure_compare(user.token, token) @current_user = user else return UnauthenticatedError end
end
ActionController::HttpAuthentication::Tokenparses Authorization header which holds the token. Actually, an Authorization header looks like that:
Authorization: Token [email protected], token="f42f5ccee3689209e7ca8e4f9bd830e2"
The email is needed to avoid timming attacks (more info here).
Now that we have set the
current_userit's time to move on to authorization.
For authorization we will use Pundit, a minimalistic yet wonderful gem based on policies. It's worth mentioning that authorization should be the same regardless of the API version, so no namespacing here. The original Rails app doesn't have an authorization gem but uses a custom one (nothing wrong with that!)
After we add the gem and run the generators for default policy we create the user policy:
class UserPolicy < ApplicationPolicy def show? return true enddef create? return true end
def update? return true if user.admin? return true if record.id == user.id end
def destroy? return true if user.admin? return true if record.id == user.id end
class Scope < ApplicationPolicy::Scope def resolve scope.all end end end
The problem with
Punditis that it has a black-white kind of policy. Either you are allowed to see the resource or not allowed at all. We would like to have a mixed-policy (the grey one): you are allowed but only to specific resource attributes.
In our app we will have 3 roles: * a
Guestwho is asking API data without authenticating at all * a
Regularuser * an
Admin, think it like God which has access to everything
For that we will use FlexiblePermissions a gem that works on top of
Pundit. Basically the idea is that apart from telling controller if this user is allowed to have access or not, you also embed the type of access: which attributes the user has access. You can also specify the defaults (which is a subset of the permitted attributes) if the user is not requesting specific fields. So, first let's specify the permission classes for
Userroles:
class UserPolicy < ApplicationPolicy class Admin < FlexiblePermissions::Base class Fields < self::Fields def permitted super + [ :links ] end end endclass Regular < Admin class Fields < self::Fields def permitted super - [ :activated, :activated_at, :activation_digest, :admin, :password_digest, :remember_digest, :reset_digest, :reset_sent_at, :token, :updated_at, ] end end end
class Guest < Regular class Fields < self::Fields def permitted super - [:email] end end end end
As you can see
Adminrole (when requesting
User(s)) has access to everything, plus, the links attributes, which is a computed property defined inside the Serializer.
Then we have the
Regularrole (when requesting
User(s)) which inherits from
Adminbut we chop some private attributes.
Then from
Guestrole we remove even more attributes (namely, the user's email).
Having defined the roles, we can now define the authorization methods for
Userresource:
class UserPolicy < ApplicationPolicy def create? return Regular.new(record) enddef show? return Guest.new(record) unless user return Admin.new(record) if user.admin? return Regular.new(record) end end
That's the classic CRUD of a resource. As you can see, for user creation we set
Regularpermissions no matter what. For the rest actions though (here showing only
showaction), we alternate between roles depending on the user. Let's see how our controller becomes now:
def show auth_user = authorize_with_permissions(User.find(params[:id]))render jsonapi: auth_user.record, serializer: Api::V1::UserSerializer, fields: {user: auth_user.fields}
end
From the controller, we specify which attributes the serializer is allowed to return, based on the
authorize_with_permissions. So for a guest, the response becomes:
{ "data": { "id": "1", "type": "users", "attributes": { "name": "Example User", "created-at": "2016-11-05T10:15:26Z" }, "relationships": { "microposts": { "links": { "related": "/api/v1/microposts?user_id=1" } }, "followers": { "links": { "related": "/api/v1/users/1/followers" } }, "followings": { "links": { "related": "/api/v1/users/1/followings" } } } } }
Pagination is necessary for 2 reasons. It adds some very basic hypermedia for the front-end client and it increases the performance since it renders only a fraction of the total resources.
For pagination we will use the same gem that Michael is already using: will_paginate. we will only need to use it in the following 2 methods:
def paginate(resource) resource = resource.page(params[:page] || 1) if params[:per_page] resource = resource.per_page(params[:per_page]) endreturn resource
end
#expects paginated resource! def meta_attributes(object) { current_page: object.current_page, next_page: object.next_page, prev_page: object.previous_page, total_pages: object.total_pages, total_count: object.total_entries } end
I should note that you can also use Kaminari, they are almost identical.
Rate limit is a good way to filter unwanted bots or users that abuse our API. It's implemented by redis-throttle gem and as the name suggests it uses redis to store the limits based on the user's IP. We only need to add the gem and add a couple of lines lines in a new file in
config/rack_attack.rb
class Rack::Attack redis = ENV['REDISTOGO_URL'] || 'localhost' Rack::Attack.cache.store = ActiveSupport::Cache::RedisStore.new(redis)throttle('req/ip', limit: 1000, period: 10.minutes) do |req| req.ip if req.path.starts_with?('/api/v1') end end
and enable it in
config/application.rb:
config.middleware.use Rack::Attack
CORS is a specification that "that enables many resources (e.g. fonts, JavaScript, etc.) on a web page to be requested from another domain outside the domain from which the resource originated. Essentially it allows us to have loaded the javascript client in another domain from our API and allow the js to send AJAX requests to our API.
For Rails all we have to do is to install the
rack-corsgem and allow:
config.middleware.insert_before 0, "Rack::Cors" do allow do origins '*' resource '*', headers: :any, methods: [:get, :post, :put, :patch, :delete, :options, :head] end end
We allow access from anywhere, as a proper API. We can set restrictions on which clients are allowed to access the API by specifying the hostnames in origins.
Now let's go and write some tests! We will use
Rack::Testhelper methods as described here. When building APIs it's important to test that the path input -> controller -> model -> controller -> serializer -> output works ok. That's why I feel API tests stand between unit tests and integration tests. Note that since Michael has already added some model tests we don't have to be pedantic about it. We can skip models, and test only API controllers.
describe Api::V1::UsersController, type: :api do context :show do before do create_and_sign_in_user @user = FactoryGirl.create(:user)get api_v1_user_path(@user.id), format: :json end it 'returns the correct status' do expect(last_response.status).to eql(200) end it 'returns the data in the body' do body = JSON.parse(last_response.body, symbolize_names: true) expect(body.dig(:data, :attributes, :name).to eql(@user.name) expect(body.dig(:data, :attributes, :email).to eql(@user.name) expect(body.dig(:data, :attributes, :admin).to eql(@user.admin) expect(body.dig(:data, :attributes, :updated_at)).to eql(@user.created_at.iso8601) expect(body.dig(:data, :attributes, :updated_at)).to eql(@user.updated_at.iso8601) end
end end
create_and_sign_in_usermethod comes from our authentication helper:
module AuthenticationHelper def sign_in(user) header('Authorization', "Token token=\"#{user.token}\"") enddef create_and_sign_in_user user = FactoryGirl.create(:user) sign_in(user) return user end alias_method :create_and_sign_in_another_user, :create_and_sign_in_user
def create_and_sign_in_admin admin = FactoryGirl.create(:admin) sign_in(admin) return admin end alias_method :create_and_sign_in_admin_user, :create_and_sign_in_admin end
RSpec.configure do |config| config.include AuthenticationHelper, type: :api end
What do we want to test?
What we are actually doing here is that I re-implement the RSpecs methods respond_to and rspec-rails' be_valid method at a higher level. However, asserting each attribute of the API response to be equal with our initial object takes too much time and space. And what if I change my serializer and use HAL or JSONAPI instead?
Instead, we can use rspec-api_helpers which automate this process:
require 'rails_helper'describe Api::V1::UsersController, type: :api do context :show do before do create_and_sign_in_user FactoryGirl.create(:user) @user = User.last!
get api_v1_user_path(@user.id) end it_returns_status(200) it_returns_attribute_values( resource: 'user', model: proc{@user}, attrs: [ :id, :name, :created_at, :microposts_count, :followers_count, :followings_count ], modifiers: { created_at: proc{|i| i.in_time_zone('UTC').iso8601.to_s}, id: proc{|i| i.to_s} } )
end end
This gem adds an automated way to test your JSONAPI (or any other API spec) respone by proviging you a simple API to test all attributes.
Furthermore, to have more robust tests, we can add rspec-json_schema that tests if the response follows a pre-defined JSON schema. For instance, the JSON schema for regular role, is the following:
{ "$schema": "http://json-schema.org/draft-04/schema#", "type": "object", "properties": { "data": { "type": "object", "properties": { "id": { "type": "string" }, "type": { "type": "string" }, "attributes": { "type": "object", "properties": { "name": { "type": "string" }, "email": { "type": "string" }, "created-at": { "type": "string" } }, "required": [ "name", "email", "created-at", ] }, "relationships": { "type": "object", "properties": { "microposts": { "type": "object", "properties": { "links": { "type": "object", "properties": { "related": { "type": "string" } }, "required": [ "related" ] } }, "required": [ "links" ] }, "followers": { "type": "object", "properties": { "links": { "type": "object", "properties": { "related": { "type": "string" } }, "required": [ "related" ] } }, "required": [ "links" ] }, "followings": { "type": "object", "properties": { "links": { "type": "object", "properties": { "related": { "type": "string" } }, "required": [ "related" ] } }, "required": [ "links" ] } }, "required": [ "microposts", "followers", "followings" ] } }, "required": [ "id", "type", "attributes", "relationships" ] } }, "required": [ "data" ] }
Notice on the
requiredand
additionalPropertiesproperties which tighten the schema a lot. Eventually the test spec for
showaction becomes:
require 'rails_helper'describe Api::V1::UsersController, '#show', type: :api do describe 'Authorization' do context 'when as guest' do before do FactoryGirl.create(:user) @user = User.last!
get api_v1_user_path(@user.id) end it_returns_status(401) it_follows_json_schema('errors') end context 'when authenticated as a regular user' do before do create_and_sign_in_user FactoryGirl.create(:user) @user = User.last! get api_v1_user_path(@user.id) end it_returns_status(200) it_follows_json_schema('regular/user') it_returns_attribute_values( resource: 'user', model: proc{@user}, attrs: [ :id, :name, :created_at, :microposts_count, :followers_count, :followings_count ], modifiers: { created_at: proc{|i| i.in_time_zone('UTC').iso8601.to_s}, id: proc{|i| i.to_s} } ) end context 'when authenticated as an admin' do before do create_and_sign_in_admin FactoryGirl.create(:user) @user = User.last! get api_v1_user_path(@user.id) end it_returns_status(200) it_follows_json_schema('admin/user') it_returns_attribute_values( resource: 'user', model: proc{@user}, attrs: User.column_names, modifiers: { [:created_at, :updated_at] => proc{|i| i.in_time_zone('UTC').iso8601.to_s}, id: proc{|i| i.to_s} } ) end
end end
Given that JSON schemas can be very verbose and specific regarding the response attributes I feel all these techniques combined can give us very powerful tests.
As you might noticed, we have skipped some stuff like creating or updating a user. That was intentional as I didn't want to overload you with information. You can dig in the code and see how everything is implemented :)
Just for reference, this API is used for the Ember app that imitates Rails Tutorial app.
For authentication and authorization in the ember side we used the ember-simple-auth addon although we haven't used devise in Rails app. But that's the beauty of APIs: you can hide your implementation details :)
In the following sections I highlight some important aspects you should take into account when building APIs. All of them (except UUIDs and model caching) have been implemented in the final API that you will find in the github repo. I really think you should take it a drive and try to add model caching (I would suggests shopify's identity_cache) if you wanna scale :)
When our resource includes a day, it's good to have it in UTC time and iso8601 format. In general, we really don't want to include anywhere timezones in our API. If we clearly state that our datetimes are in utc and we only accept utc datetime, clients are responsible to convert the utc datetime to their local datetime (for instance, in Ember this is very easy using moment and transforms).
Another thing is that when building an API we should always think from the client perspective. For instance if the client requests a user, it will probably like to know the number of microposts, followers or followings (users the user follows) that user has.
At the moment, this can be achieved by sending an extra request to each one of those resources and check the
total_countof the meta in the response. Having the client sending more requests is not good for the client, it's not good for us either since this means more requests to our API.
Instead we can add (cache) counters to each of the associations and return those along with the user information. To achieve that, we first need to create a column for each counter and then tell rails to cache the counters (by adding
counter_cache: assocation_countin each association). Here we go:
First we create a migration:
ruby class AddCacheCounters < ActiveRecord::Migration[5.0] def change add_column :users, :microposts_count, :integer, null: false, default: 0 add_column :users, :followers_count, :integer, null: false, default: 0 add_column :users, :followings_count, :integer, null: false, default: 0 end endThen inside
Micropostmodel:
belongs_to :user, counter_cache: true
and inside
Relationshipmodel:
belongs_to :follower, class_name: "User", counter_cache: :followings_count belongs_to :followed, class_name: "User", counter_cache: :followers_count
I should note that in regular Rails development these counter cache columns are added even when not having an API. It helps a lot to cache them in a database column instead of running the
SQL COUNT(*)each time we need it.
Ok let's thing from the client perspective again. Let's say that the client wants to retrieve a user, so it gets the user information along with the counters. However, in most cases you will want to know whether you follow this user or not and whether this user follows you or not.
In a regular Rails app we can do instantly (even from the view) the query, or use a helper and figure it out. Here we need to take a different approach instead. It will cost us much less if we give this information beforehand instead of creating a new endpoint just for that and letting the client do the request.
We will add these states in the serializers as computed properties:
attribute :following_state attribute :follower_statedef following_state Relationship.where( follower_id: current_user.id, followed_id: object.id ).exists? end
def follower_state Relationship.where( follower_id: object.id, followed_id: current_user.id ).exists? end
We should cache this information (but I leave it up to you how to do it :) )
Even if you feel that this information is rarely used by clients, you should still have it in the user resource but instead of providing these resource attributes by default, you can provide them only when the user specifies a JSONAPI
fieldsparam. Which brings us to the next topic: help the client by building a modern API. Remember that you don't build the API for yourself but for the clients. The better the API for the clients, the more API clients you will have :)
A modern API, regadless the spec you use, should have at least the following attributes:
These API attributes will help the client to avoid unessecary data and ask for exactly what is needed helping us too (since we won't compute unused data). Ideally we would like to give to the client an ORM on top of HTTP.
We have already solved the problem of granular permissions by using flexible_permissions roles. Each role is allowed only specific attributes and associations. Also the same gems allows us to select only a subset of the allowed fields.
JSONAPI already specified how a client can ask specific fields/associations of a resource. What we need to do now is to link the user's asked fields/associaions with role's permitted attributes and associations.
We have already set the defaults using flexible_permissions. We have also added pagination in our response.
Now we need to allow the client to ask for specific sorting, filtering collections by sending custom queries and ask for aggregated data (for instance the average number of followers of a user).
For those things we are going to use activehashrelation gem which adds a whole API in our index method for free! Be sure to check it out! It's as simple as adding 2 lines:
class Api::V1::UsersController < Api::V1::BaseController include ActiveHashRelationdef index auth_users = policy_scope(@users)
render jsonapi: auth_users.collection, each_serializer: Api::V1::UserSerializer, fields: {user: auth_users.fields}, meta: meta_attributes(auth_users.collection)
end end
Now, using ActiveHashRelation API we can ask for users that were created after a specific date or users with a specific email prefix etc. We can also ask for specific sorting and aggregation queries.
However, it's a good idea in terms of performance and security to first filter the permitted params
A new Rails project without automatic deployment is not cool. Services like travis, circleci and codeship help us build and deploy faster. In this project we will use codeship.
Once we create a new project we we need to add the following commands on setup section:
rvm use 2.3.3 bundle install bundle exec rake db:create bundle exec rake db:migrateIn test secion we can run all tests (both Michael's and API tests):
rake test bundle exec rspec spec
Then we need to create a heroku app (if heroku is what we want for code hosting) and get the API key (I am surprised that heroku doesn't provide any permission listing for its API keys :/) which is required by Codeship (or any other automatic deployment service) to deploy the code. Once we have it we add a heroku pipeline and we are ready.
Now If we commit to master and our tests are green, it will push and deploy our repo in heroku and run migrations :)
We build our API, we ship it and everything works as expected. We can always add more endpoints or enhance current ones and keep our current version as long as we don't have a breaking changes. However, although rare, we might reach the point where we must have a break change because the requirements changed. Don't panic! All we have to do is define the same routes but for V2 namespace, define the V2 controllers that inherit from V1 controllers and override any method we want.
class Api::V2::UsersController < Api::V1::UsersControllerdef index #new overriden index here end
end
In that way we save a lot of time and effort for our V2 API ( although for shifting an API version you will probably want more changes than a single endpoint).
Documenting our API is vital even if it supports hypermedia. Documentation helps users to speed up their app or client development. There are many documentation tools for rails like swagger and slate.
Here we will use slate as it is easier to start with.
Our app is rather small and we are going to have docs in the same repo with the rails app but in larger APIs we might want them in a separate repository because it generates css and html files which are also versioned and there is no point since they are generated with a bundler command.
Create an
app/docs/directory and clone the slate repository there and delete the .git directory (we don't need slate revisions). In a
app/docs/config.rbset the build directory to public folder:
set :build_dir, '../public/docs/'
and start writing your docs. You can take some inspiration from our docs :)
As I mentioned there are 2 things that haven't implemented, but you should try to implement them as a test :)
First, it's a good idea is to use uuids instead of ids when we know that our app is going to have an API. With ids we might unveil sensitive information to an attacker. There is a slight performance hit on database when using UUIDs but probably the benefits are greater. You can also check this blog post for more information.
Secondly we haven't added any caching. In my experience a Rails app like that should stand around 1000 req/minute in a regular heroku dyno X2 (3 puma processes each having 2 workers, each having ~10 threads giving us in total 60 fronts) but adding cache should take it to 2500. However I haven't tested that. Is anyone interested to tell me how much he/she manage to reach? (with or without cache). I would be happy to add an extra sections just for optimizations from you folks. Just create a PR :D
That's all for now. You should really start building your Rails API today and not tomorrow.
I am now going to prepare the Ember tutorial. Until then take care and have fun!
Did you know that you can contribute to this tutorial by opening an issue or even sending a pull request?