Building an API

For sucessful build API the design and documentation are the key.While documenting you must keep your users(who are developers) .

We will be build a todos application which allow users to create their todos, these todos have tasks which can either be marked as done or not.Once all items in the todo are marked as done the todo will be delete.

Generating the app

1
$ rails new Todos --api --skip-test --database=postgresql

Setting up dependencies

Let us start by adding Cross-Origin Resource Sharing (CORS) gem and jbuilder for sending and receiving json object with ease.

Cross-Origin Resource Sharing (CORS) is a mechanism that uses additional HTTP headers to tell browsers to give a web application running at one origin, access to selected resources from a different origin.

For more on CORS checkout here.

Gemfile

1
2
3
# truncated
gem 'jbuilder', '~> 2.7'
gem 'rack-cors'

Now let us adding debugging dependencies in our development and test environment like so:

Gemfile

1
2
3
4
5
group :development, :test do
# truncated
  gem 'pry-rails'
  gem 'pry-byebug'
end

On the same environment that is test and development setup, the following test depedencies

  • rspec rails
  • shoulda_matchers
  • FactoryBot

Simple test

Let us create an test to figure out if our endpoint able to receive requests.

We hope that by end of it when we send request to url/endpoint, http://localhost:3000/api/v0/pings will get a response which will look like so: {"message":"Pong"}

Let us start with the test

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
require  'rails_helper'
RSpec.describe Api::V0::PingsController, type: :request do
    describe 'GET /v0/ping' do
        it  'should return Ping' do
            get '/api/v0/pings'

            json_response = JSON.parse(response.body)
            expect(response.status).to eq 200
            expect(json_response['message']).to eq 'Pong'
        end
    end
end

The implementation of simple test

Let use setup our routes like so

config/routes.rb

1
2
3
4
5
  namespace :api do
    namespace :v0 do
      resources :pings, only: [:index], constraints: { format: 'json' }
    end
  end

The namespace specifically indicate our route is under version 0(vO) and under api as whole i.e our routes looks like this /api/v0/pings and since we taking only action it has a get method and it handle request in json format only.

Let us now create our controller like so:

app/controllers/api/v0/pings_controller.rb

1
2
3
4
5
class Api::V0::PingsController < ApplicationController
    def index
        render json: { message: 'Pong'}
    end
end

Controller can render in different format, I know you have met html format, there is pdf format and so on.Our specifically render json format.

if you visit localhost:3000/api/v0/pings You should see {"message":"Pong"} Perfect our application is working great now let us move on.

User Registration and Login

We will use devise auth gem.

Setting up devise

Gemfile

1
2
# truncated
  gem 'devise_token_auth'

bundle

Run the generator like so:

1
$ rails generate devise:install

At this point, a number of instructions will appear in the console. Among these instructions, you'll need to set up the default URL options for the Devise mailer in each environment. Here is a possible configuration for config/environments/development.rb: config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }

Generating user model

We will be using devise generators.

1
$ rails generate devise User 

Change this line in user migration file from

xxxxxxxx_devise_token_auth_create_users.rb

1
2
      ## Tokens      
      t.json :tokens

to like show:

xxxxxxxx_devise_token_auth_create_users.rb

1
2
      ## Tokens      
      t.text :tokens

Let us migrate.

1
$ rails db:migrate

User model validations tests

let start by creating the user factory

spec/Factories/users.rb

1
2
3
4
5
6
7
FactoryBot.define do
  factory :user do
    email {'[email protected]'}
    password {'password'}
    password_confirmation {'password'}
  end
end

Now let us test the validations

/spec/models/user_spec.rb

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
require 'rails_helper'

RSpec.describe User, type: :model do
    it 'should have valid Factory' do
      expect(create(:user)).to be_valid  
    end
    describe 'User table' do
        it { is_expected.to have_db_column :id }
        it { is_expected.to have_db_column :provider }
        it { is_expected.to have_db_column :uid }
        it { is_expected.to have_db_column :encrypted_password }
        it { is_expected.to have_db_column :reset_password_token }
        it { is_expected.to have_db_column :reset_password_sent_at }
        it { is_expected.to have_db_column :remember_created_at }
        it { is_expected.to have_db_column :sign_in_count }
        it { is_expected.to have_db_column :current_sign_in_at }
        it { is_expected.to have_db_column :last_sign_in_at }
        it { is_expected.to have_db_column :current_sign_in_ip }
        it { is_expected.to have_db_column :last_sign_in_ip }
        it { is_expected.to have_db_column :confirmation_token }
        it { is_expected.to have_db_column :confirmed_at }
        it { is_expected.to have_db_column :confirmation_sent_at }
        it { is_expected.to have_db_column :unconfirmed_email }
        it { is_expected.to have_db_column :nickname }
        it { is_expected.to have_db_column :image }
        it { is_expected.to have_db_column :email }
        it { is_expected.to have_db_column :tokens }
        it { is_expected.to have_db_column :created_at }
        it { is_expected.to have_db_column :updated_at }
    end

    describe 'Validations' do
        it { is_expected.to validate_presence_of(:email) }
        it { is_expected.to validate_confirmation_of(:password) }

        context 'should not have an invalid email address' do
            emails = ['[email protected] ds.com', '@example.com', 'test me @yahoo.com',
                        '[email protected]', '[email protected] .d', '[email protected]']

            emails.each do |email|
                it { is_expected.not_to allow_value(email).for(:email) }
            end
        end

        context 'should have a valid email address' do
            emails = ['[email protected]', '[email protected]', '[email protected]',
                        '[email protected]']

            emails.each do |email|
            it { is_expected.to allow_value(email).for(:email) }
            end
        end
    end
end

Run the test .

Request test

Let us now test if the request actually reach our endpoint and appropriate response is given

setting up JSON helper for rspec

We have a custom helper method json which parses the JSON response to a Ruby Hash which is easier to work with in our tests.Let us set it up.

spec/support/response_json.rb

1
2
3
4
5
6
7
8
9
module ResponseJSON
  def response_json
    JSON.parse(response.body)
  end
end

RSpec.configure do |config|
  config.include ResponseJSON
end

We simple include a method which parses JSON to be used anywhere in our test

User Signup/Registration test

We are expect that is sign up is success an http status code 200 will be sent back as response

spec/requests/api/v1/user_signup_spec.rb

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
RSpec.describe  'Users Registration', type: :request do
    let(:header ) {{HTTP_ACCEPT: 'application/json'}}
    context 'with valid credentials' do
        it 'returns user token' do
            post '/api/v1/auth', params: {
            email: '[email protected]', password: 'password',
            password_confirmation: 'password'
            }, headers: headers
            expect(response_json['status']).to eq  'success'
            expect(response.status).to eq  200
        end
    end
    context 'returns an error message when user submits' do
        it 'non-matching password confirmation' do
          post '/api/v1/auth', params: {
            email: '[email protected]', password: '12333',
            password_confirmation: 'password'
          }, headers: headers

          expect(response_json['errors']['password_confirmation'])
            .to eq ["doesn't match Password"]
          expect(response.status).to eq 422
        end

        it 'an invalid email address' do
          post '/api/v1/auth', params: {
            email: 'Gijoe', password: 'password',
            password_confirmation: 'password'
          }, headers: headers

          expect(response_json['errors']['email']).to eq ['is not an email']
          expect(response.status).to eq 422  
        end

        it 'an already registered email' do
          FactoryBot.create(:user, email: '[email protected]',
                                    password: 'password',
                                    password_confirmation: 'password')

          post '/api/v1/auth', params: {
            email: '[email protected]', password: 'strongpass',
            password_confirmation: 'strongpass'
          }, headers: headers

          expect(response_json['errors']['email']).to eq ['has already been taken']
          expect(response.status).to eq 422
        end
    end
end    

If we run the test we get routes failure let us fix that config/routes.rb

1
2
3
4
5
6
namespace :api do
  #namespace for v0 truncated for brevity
  namespace :v1, default: {format: :json}do
      mount_devise_token_auth_for 'User', at: 'auth', skip: [:omniauth_callbacks]
  end
end

Session

We test both the creation and deletion of session i.e signing in and signing out

spec/requests/api/v1/user_sessions_spec.rb

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
RSpec.describe 'Sessions', type: :request do
  let(:user) { FactoryBot.create(:user) }
  let(:headers) { { HTTP_ACCEPT: 'application/json' } }

  describe 'POST /api/v1/auth/sign_in' do
    it 'valid credentials allow user sign in' do
      post '/api/v1/auth/sign_in', params: {
        email: user.email, password: user.password
      }, headers: headers

      expected_response = {
        'data' => {
          'id' => user.id, 'email' => user.email,
          'provider' => 'email',
          'allow_password_change' => false
        }
      }
      expect(response_json).to eq expected_response
    end
    it 'invalid password returns errors' do
      post '/api/v1/auth/sign_in', params: {
        email: user.email, password: 'wrong_password'
      }, headers: headers

      expect(response_json['errors'])
        .to eq ['Invalid login credentials. Please try again.']
      expect(response.status).to eq 401
    end
    it 'invalid email returns error message' do
      post '/api/v1/auth/sign_in', params: {
        email: '[email protected]', password: user.password
      }, headers: headers

      expect(response_json['errors'])
        .to eq ['Invalid login credentials. Please try again.']
      expect(response.status).to eq 401
    end
  end
  it 'logouts' do
    post '/api/v1/auth/sign_in', params: {
      email: user.email, password: user.password
    }, headers: headers

    delete '/api/v1/auth/sign_out', params: {
      'access-token' => response.header['access-token'],
      'client' => response.header['client'],
      'uid' => response.header['uid']
    }
    expect(response.status).to eq 200
  end
end

API documentation

An api should be well documented, indicated what it does, the endpoints it has.The http methods, headers and body format when hitting those endpoints.It should also illustrate constraints such as data types to be used when sending request and

Setting up rswag

On your Gemfile add gem rswag under no scope.Run the bundle.

Now run rails g rswag:install.

Open the spec/swagger_helper.rb and describe your API accordingly.

Change the title and the description.

Write the docs

On the spec/requests/api/v1/user_signup_spec.rb just below require 'rails_helper' statement

and above RSpec describe block

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# below 'rails_helper
require 'swagger_helper'

describe 'User' do

  path '/api/v0/auth' do

    post 'user registration/Sign up' do
      tags 'Users'
      consumes 'application/json'
      parameter name: :user, in: :body, schema: {
        type: :object,
        properties: {
          email: { type: :string },
          password: { type: :string },
          password_confirmation: { type: :string },
        },
        required: [ 'email', 'password', 'password_confirmation' ]
      }

      response '200', 'success' do
        let(:user) { { email: '[email protected]', password: '[email protected]', password_confirmation: '[email protected]'  } }
        run_test!
      end
    end
    end
  end
  # above RSpec describe block

Try write docs for invalid email.Then call me as soon as you are done.For further instructions.


Exercise

Note: User can perform these operations after authentication

Todos resource

  1. Allow user to create todo which has a title, created by(the name of user who created the todo) and finishing date.Remember user can create many todos.
  2. Finishing date of a todo cannot be less than current date

  3. User can update the finishing date and title of a todo

tasks resource

  1. An task has a name and done attributes, done attribute can either be true meaning task is finished or false if it is not.

  2. Todo has many tasks and task or tasks belonging to a todo can be created during creation or after creation of todo by the user.

  3. Once all tasks in a todo are done, that todo should be delete automatically.