Scaffolding a Ruby on Rails Api

Visual Studio Code Setup

First, I recommend getting VSCode’s Remote Container set up to allow for container-based development and not requiring adding dependencies to your local machine.

Create a directory that will store your Rails project.

mkdir rails-api cd rails-api

Then download the Remote Containers VSCode extension, and add a .devcontainer file using the Ruby on Rails community image.

Scaffolding the Rails API

Inside the container, create the Rails API using rails new . --api.

You can now push this

Set up Debugging for VSCode

To set up debugging inside VSCode, run the following commands:

bundle add ruby-debug-ide debase

Then add the following to .vscode/launch.json:

{
    // Use IntelliSense to learn about possible attributes.
    // Hover to view descriptions of existing attributes.
    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
    "version": "0.2.0",
    "configurations": [{
        "name": "Rails server",
        "type": "Ruby",
        "request": "launch",
        "cwd": "${workspaceRoot}",
        "program": "${workspaceRoot}/bin/rails",
        "pathToRDebugIDE": "~/.asdf/shims/rdebug-ide",
        "showDebuggerOutput": true,
        "args": [
          "server"
        ]
    }]
}

You’ll now be able to run and debug the API locally at port 3000.

Finally, edit the .devcontainer/devcontainer.json file to have postCreateCommand use bundle install so dependencies for the container will always load on build.

Create endpoint

To create an endpoint, use the following command:

rails g scaffold <entity> <fields>

So as an example:

rails g scaffold person name:string title:string

Start the application and view the endpoint created above, noting that CRUD functionality is built in.

Seeding Data

You may want to seed data - to do so, edit the seeds.rb file to include data for your class. After that, run the rake db:setup command to set up the DB with the migrations and seeded data correctly.

An easy way to automate this is to add the rake db:reset step to the postCreateCommand script for the Remote Container, running bundle install && rake db:reset. This will clear the DB on rebuild of the container, and run all migrations.

Setting up CORS

To set up CORS capabilities, check this guide

You can then verify with this command

Setting up authentication with JWT

First, add the jwt and bcrypt gems:

bundle add jwt bcrypt

Then, create a user object that will hold sign-in information:

rails g scaffold user name:string email:string password_digest:string

and edit the model created:

class User < ApplicationRecord
    has_secure_password

    validates_presence_of :name, :email, :password_digest
end

then add the following lines to user_controller.rb:

# Allows endpoint(s) to be accessed without authorization
skip_before_action :authorize_request, :only => :create

and modify the create action:

...
# POST /users
  def create
    user = User.create!(user_params)
    auth_token = AuthenticateUser.new(user.email, user.password).call
    response = { message: "Account created.", auth_token: auth_token }
  end
...
# Only allow a list of trusted parameters through.
    def user_params
      params.permit(:name, :email, :password)
    end

Then create app/lib/json_web_token.rb:

class JsonWebToken
    HMAC_SECRET = Rails.application.secrets.secret_key_base

    def self.encode(payload, exp = 24.hours.from_now)
        payload[:exp] = exp.to_i
        JWT.encode(payload, HMAC_SECRET)
    end

    def self.decode(token)
        body = JWT.decode(token, HMAC_SECRET)[0]
        HashWithIndifferentAccess.new body

    rescue JWT::DecodeError => e
        raise ExceptionHandler::InvalidToken, e.message
    end
end

and user service app/auth/authenticate_user.rb:

class AuthenticateUser
    def initialize(email, password)
        @email = email
        @password = password
    end

    def call
        JsonWebToken.encode(user_id: user.id) if user
    end

    private

    attr_reader :email, :password

    # verifies user credentials
    def user
        user = User.find_by(email: email)
        return user if user && user.authenticate(password)

        raise (ExceptionHandler::AuthenticationError, "Invalid credentials.")
    end
end

and authorize API request service app/auth/authorize_api_request.rb:

class AuthorizeApiRequest
    def initialize(headers = {})
      @headers = headers
    end
  
    # Service entry point - return valid user object
    def call
      {
        user: user
      }
    end
  
    private
  
    attr_reader :headers
  
    def user
      @user ||= User.find(decoded_auth_token[:user_id]) if decoded_auth_token
      # handle user not found
    rescue ActiveRecord::RecordNotFound => e
      # raise custom error
      raise(
        ExceptionHandler::InvalidToken,
        ("Invalid token #{e.message}")
      )
    end
  
    # decode authentication token
    def decoded_auth_token
      @decoded_auth_token ||= JsonWebToken.decode(http_auth_header)
    end
  
    # check for token in `Authorization` header
    def http_auth_header
      if headers['Authorization'].present?
        return headers['Authorization'].split(' ').last
      end
        raise(ExceptionHandler::MissingToken, "Missing token.")
    end
end

Then create the authentication controller:

rails g scaffold_controller authentication

and edit to look like the following:

class AuthenticationController < ApplicationController
  skip_before_action :authorize_request, :only => :authenticate

  # POST /login
  def authenticate
    auth_token = AuthenticateUser.new(authentication_params[:email], authentication_params[:password]).call

    render json: { auth_token: auth_token }
  end

  private

  def authentication_params
    params.fetch(:authentication, {})
  end
end

Create the ExceptionHandler class at app/controllers/concerns/exception_handler.rb:

module ExceptionHandler
    extend ActiveSupport::Concern
  
    # Define custom error subclasses - rescue catches `StandardErrors`
    class AuthenticationError < StandardError; end
    class MissingToken < StandardError; end
    class InvalidToken < StandardError; end
  
    included do
      # Define custom handlers
      rescue_from ActiveRecord::RecordInvalid, with: :four_twenty_two
      rescue_from ExceptionHandler::AuthenticationError, with: :unauthorized_request
      rescue_from ExceptionHandler::MissingToken, with: :four_twenty_two
      rescue_from ExceptionHandler::InvalidToken, with: :four_twenty_two
  
      rescue_from ActiveRecord::RecordNotFound do |e|
        render json: { message: e.message }, status: :not_found
      end
    end
  
    private
  
    # JSON response with message; Status code 422 - unprocessable entity
    def four_twenty_two(e)
      render json: { message: e.message }, status: :unprocessable_entity
    end
  
    # JSON response with message; Status code 401 - Unauthorized
    def unauthorized_request(e)
      render json: { message: e.message }, status: :unauthorized
    end
end

Now add the following to application_controller.rb:

class ApplicationController < ActionController::API
    skip_before_action :authorize_request, only: :authenticate, raise: false

    include ExceptionHandler

    # will call the below command on every controller action unless exempted
    before_action :authorize_request
    attr_reader :current_user

    def authorize_request
        @current_user = (AuthorizeApiRequest.new(request.headers).call)[:user]
    end
end

Finally, add the route for logging in at routes.rb:

...
post '/login', to: 'authentication#authenticate'
...

Adding new user

With the configuration above, you can add a user by POSTing to /user with this payload:

{
  "name": "NAME",
  "email": "EMAIL",
  "password": "PASSWORD"
}

Logging in

Deploying to Heroku

Setting up PostgreSQL

To deploy to Heroku, the Rails app needs to use Postgresql instead of sqlite.

First, set up Postgresql in your development container using this guide.

Next, set up the appropriate gems:

bundle add pg
bundle remove sqlite3

Finally, edit config/database.yml:

default: &default
  adapter: postgresql
  encoding: unicode
  host: db
  user: postgres
  password: <%= ENV.fetch("DB_PASSWORD") %>
  # For details on connection pooling, see Rails configuration guide
  # https://guides.rubyonrails.org/configuring.html#database-pooling
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>

Rebuild the container and run locally to verify everything working.

Deploying

Once this is done, you can deploy the app to Heroku. You can do this manually using Heroku CLI, or you can connect the app via GitHub and Heroku to enable automatic deployments. If you run into issues, you can pull the logs from Heroku CLI.

After the app is deployed, you’ll need to migrate the DB changes using:

heroku run rake db:migrate

If seed data is desired, you can then run

heroku run rake db:seed