DEV Community

Cover image for Ruby on Rails API with Vue.js
Andy Leverenz
Andy Leverenz

Posted on • Originally published at web-crunch.com on

Ruby on Rails API with Vue.js

Did you know Ruby on Rails can be used as a strict API based backend application? What’s the benefit to this? Think of it as a single source of truth for multiple future applications to absorb and use this data directly. Anything from a native mobile application, to a front-end framework, can talk with this data. Many apps can essentially communicate with a “source of truth” in return which means more consistent applications for all.

In this build, I’ll be crafting a simple but thorough application where Ruby on Rails is our backend and Vue.js + Axios is our front-end. I’ll create two apps that communicate in order to achieve the same result of a normal Rails-based app but with all the perks of an API.

Used in this build

What are we building exactly?

This app at its core is simple. It will be an archive of vinyl records for sale and categorized by artist. We won’t be implementing a ton of foreign logic but rather just getting the foundations of an API-based application in order. We’ll touch on authentication (not using Devise) and basic CRUD.

There will be two apps.

  • A Ruby on Rails backend – This will handle our data, sessions, and authentication.
  • A Vue.js frontend – This will be the view layer but also the one responsible for sending and receiving data to our rails-based backend. The front-end will run on a different instance using the Vue-CLI to help us set up an app.

The videos

Part 1

Part 2

Part 3

Part 4

Part 5

Part 6

Part 7

Part 8

The Backend

Our backend will be a very trimmed down Rails app with no view-based layer. Rails has a handy api mode which you can initialize by passing the flag --api during the creation of a new app. Let’s dive in.

Create the app in API mode

$ rails new recordstore-back --api
Enter fullscreen mode Exit fullscreen mode

Add gems

  1. Uncomment rack-cors and bcrypt .
  2. add redis and jwt_sessions
  3. bundle install

Here’s the current state of my Gemfile

# Gemfile - Jan 2019
source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }

ruby '2.5.3'

# Bundle edge Rails instead: gem 'rails', github: 'rails/rails'
gem 'rails', '~> 5.2.2'
# Use sqlite3 as the database for Active Record
gem 'sqlite3'
# Use Puma as the app server
gem 'puma', '~> 3.11'
# Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder
# gem 'jbuilder', '~> 2.5'
# Use ActiveModel has_secure_password
gem 'bcrypt', '~> 3.1.7'

# Use ActiveStorage variant
# gem 'mini_magick', '~> 4.8'

# Use Capistrano for deployment
# gem 'capistrano-rails', group: :development

# Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', '>= 1.1.0', require: false

# Use Rack CORS for handling Cross-Origin Resource Sharing (CORS), making cross-origin AJAX possible
gem 'rack-cors'
gem 'redis', '~> 4.1'
gem 'jwt_sessions', '~> 2.3'

group :development, :test do
  # Call 'byebug' anywhere in the code to stop execution and get a debugger console
  gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]
end

group :development do
  gem 'listen', '>= 3.0.5', '< 3.2'
  # Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring
  gem 'spring'
  gem 'spring-watcher-listen', '~> 2.0.0'
end

# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby]
Enter fullscreen mode Exit fullscreen mode

Create a User model

We won’t be using Devise this time around! Rails has some handy built-ins to help users set up authentication. This route is certainly more involved but I recommend doing this to learn more about how popular gems like Devise work (and solve a lot of headaches).

To avoid too much complexity upfront our User model won’t associate with the Record or Artist model just yet. Later we can add that so a User can add both an Artist and Record to the app with the front-end interface.

$ rails g model User email:string password_digest:string
Enter fullscreen mode Exit fullscreen mode

The password_digest field will make use of the bcrypt gem we uncommented during initial setup. It creates a tokenized version of your password for better security.

We’ll need to modify the migration file to include a default of null: false on the email andpassword_digest columns.

# db/migrate/20190105164640_create_users.rb
class CreateUsers < ActiveRecord::Migration[5.2]
  def change
    create_table :users do |t|
      t.string :email, null: false
      t.string :password_digest, null: false

      t.timestamps
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Let’s migrate that in

$ rails db:migrate
Enter fullscreen mode Exit fullscreen mode

Create an Artist Model

The Artist model will be the parent relation in our app. A record (soon to come) will belong to an artist

$ rails g scaffold Artist name
Enter fullscreen mode Exit fullscreen mode

Notice how no views are created when that resource gets scaffolded? That’s again our API-mode at work. Our controllers also render JSON but default.

Create a Record Model

Our Record model will have a few more fields and belong to an artist. This scaffold creates a Record model (class) that has title, year, artist_id and user_id columns on the new records database table. This creates a new migration with all of this data in mind.

$ rails g scaffold Record title year artist:references user:references
Enter fullscreen mode Exit fullscreen mode

Migrate both models in

$ rails db:migrate
Enter fullscreen mode Exit fullscreen mode

Namespacing our API

Having scaffolded the models and data structures we need let’s talk routing. APIs often change. A common trend is to introduce versions which allow third-parties to opt into a new API version when they see fit. Doing this presents fewer errors for everyone but comes with a little more setup on the backend which mostly deals with routing and file location.

To namespace our app I want to do a v1 type of concept which ultimately looks like this:

Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do      
     # routes go here
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Namespacing allows us to extend things further at any point say if we roll out a new version or decide to build more with the backend. All of our data will live within the namespace but our user-related data won’t. We probably won’t change a lot with the userbase on the backend that would need to be in an API. Your results may vary as your app scales.

Update the routes

Next, we need to add our recently scaffolded resources to the mix

Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      resources :artists
      resources :records
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Having updated our namespacing we need to move our controllers to accommodate. Move artists_controller.rb and records_controller.rb to app/controllers/api/v1/ . Be sure to modify both to include the new namespacing like so. By the way, If your server was running you should restart it.

Here is the artists controller:

# app/controllers/api/v1/artists_controller.rb
module Api
  module V1
    class ArtistsController < ApplicationController
      before_action :set_artist, only: [:show, :update, :destroy]

      def index
        @artists = Artist.all

        render json: @artists
      end

      def show
        render json: @artist
      end

      def create
        @artist = Artist.new(artist_params)

        if @artist.save
          render json: @artist, status: :created
        else
          render json: @artist.errors, status: :unprocessable_entity
        end
      end

      def update
        if @artist.update(artist_params)
          render json: @artist
        else
          render json: @artist.errors, status: :unprocessable_entity
        end
      end

      def destroy
        @artist.destroy
      end

      private
      def set_artist
          @artist = Artist.find(params[:id])
      end

      def artist_params
          params.require(:artist).permit(:name)
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

And here’s the records_controller.rb file

module Api
  module V1
    class RecordsController < ApplicationController
      before_action :set_record, only: [:show, :update, :destroy]

      def index
        @records = current_user.records.all

        render json: @records
      end

      def show
        render json: @record
      end

      def create
        @record = current_user.records.build(record_params)

        if @record.save
          render json: @record, status: :created
        else
          render json: @record.errors, status: :unprocessable_entity
        end
      end

      def update
        if @record.update(record_params)
          render json: @record
        else
          render json: @record.errors, status: :unprocessable_entity
        end
      end

      def destroy
        @record.destroy
      end

      private
      def set_record
        @record = current_user.records.find(params[:id])
      end

      def record_params
        params.require(:record).permit(:title, :year, :artist_id)
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Getting JWT_Sessions Setup

JSON Web Tokens are how we will handle authentication in this app. Rails apps that aren’t API-based use session-based tokens to verify logins/sessions of a given User. We don’t have the same session logic available to do such a thing with an API driven frontend app. We also want our API available to other applications or things we build like a mobile app, native app, and more (the possibilities are kinda endless). This concept is why API-based applications are all the craze.

Let’s setup JWTSessions.

# app/controllers/application_controller.rb

class ApplicationController < ActionController::API
   include JWTSessions::RailsAuthorization
end
Enter fullscreen mode Exit fullscreen mode

Inside your application_controller.rb file add the following include. We get this from the gem we installed previously.

Note how your controller inherits from ActionController::API instead of the default ApplicationController. That’s the API mode in full force!

We need some exception handling for unauthorized requests. Let’s extend the file to the following:

# app/controllers/application_controller.rb

class ApplicationController < ActionController::API
  include JWTSessions::RailsAuthorization
  rescue_from JWTSessions::Errors::Unauthorized, with :not_authorized

  private

  def not_authorized
    render json: { error: 'Not Authorized' }, status: :unauthorized
  end
end
Enter fullscreen mode Exit fullscreen mode

We’ll also need an encryption key. The JWTSessions gem by default uses HS256 algorithm, and it needs an encryption key provided.

The gem uses Redis as a token store by default so that’s why you saw it in our Gemfile. We need a working redis-server instance running. It is possible to use local memory for testing but we’ll be using redis for this build as it’s what would run in production anyway. Check out the readme for more information

Create a new initializer file called jwt_sessions.rb and add the following

# config/initializers/jwt_sessions.rb

JWTSessions.encryption_key = 'secret' # use something else here
Enter fullscreen mode Exit fullscreen mode

Definitely worth using something other than your secret key here if you prefer!

Signup Endpoint

Because we are going the token-based route we can choose to either store those on the client side cookies or localStorage. It boils down to preference where you land. Either choice has its pros and cons. Cookies being vulnerable to CSRF and localStorage being vulnerable to XSS attacks.

The JWT_Sessions gem provides the set of tokens – access, refresh, and CSRF for cases when cookies are chosen as the token store option.

We’ll be making use of cookies with CSRF validations

The session within the gem comes as a pair of tokens called access and refresh. The access token has a shorter life span with a default of 1 hour. Refresh on the other hand has a longer life span of ~ 2 weeks. All of which is configurable.

We’ll do quite a bit of logic in a signup_controller file of which we can generate.

$ rails g controller signup create
Enter fullscreen mode Exit fullscreen mode

For now we can omit the route that gets generated in config/routes.rb

Rails.application.routes.draw do
    get 'signup/create' # remove this line
    ...
end
Enter fullscreen mode Exit fullscreen mode

Let’s add the logic for signup to the controller. We’ll be harnessing the JWT_Sessions gem for this.

# app/controllers/signup_controller.rb

class SignupController < ApplicationController
  def create
    user = User.new(user_params)
    if user.save
      payload = { user_id: user.id }
      session = JWTSessions::Session.new(payload: payload, refresh_by_access_allowed: true)
      tokens = session.login

      response.set_cookie(JWTSessions.access_cookie,
                          value: tokens[:access],
                          httponly: true,
                          secure: Rails.env.production?)
      render json: { csrf: tokens[:csrf] }
    else
      render json: { error: user.errors.full_messages.join(' ') }, status: :unprocessable_entity
    end
  end

  private

  def user_params
    params.permit(:email, :password, :password_confirmation)
  end
end
Enter fullscreen mode Exit fullscreen mode

A lot is going on here but it’s not too impossible to understand. We’ll point the user to the endpoint signup/create method. In doing so we accomplish the following if all goes well.

  • Create a new user with permitted parameters (email, password, password_confirmation)
  • Assign the user_id as the payload
  • Create a new token-based session using the payload & JWTSessions.
  • Set a cookie with our JWTSession token [:access]
  • render final JSON & CSRF tokens to avoid cross-origin request vulnerabilities.
  • If none of that works we render the errors as JSON

Signin/Signout Endpoint

The Sign in Controller is quite similar to the signup minus the creation of a user and what happens if a user can’t sign in successfully. There’s the create method but also a destroy method for signing a user out.

# app/controllers/signin_controller.rb

aclass SigninController < ApplicationController
  before_action :authorize_access_request!, only: [:destroy]

  def create
    user = User.find_by!(email: params[:email])
    if user.authenticate(params[:password])
      payload = { user_id: user.id }
      session = JWTSessions::Session.new(payload: payload, refresh_by_access_allowed: true)
      tokens = session.login
      response.set_cookie(JWTSessions.access_cookie,
                        value: tokens[:access],
                        httponly: true,
                        secure: Rails.env.production?)
      render json: { csrf: tokens[:csrf] }
    else
      not_authorized
    end
  end

  def destroy
    session = JWTSessions::Session.new(payload: payload)
    session.flush_by_access_payload
    render json: :ok
  end

  private

  def not_found
    render json: { error: "Cannot find email/password combination" }, status: :not_found
  end
end
Enter fullscreen mode Exit fullscreen mode

We render the not_authorized method which comes from our Application controller private methods if a sign in is unsuccessful.

The Refresh Endpoint

Sometimes it’s not secure enough to store the refresh tokens in web / JS clients. We can operate with token-only with the help of the refresh_by_access_allowed method you’ve been seeing so far. This links the access token to the refresh token and refreshes it.

Create a refresh_controller.rb file and include the following:

# app/controllers/refresh_controller.rb
class RefreshController < ApplicationController
  before_action :authorize_refresh_by_access_request!

  def create
    session = JWTSessions::Session.new(payload: claimless_payload, refresh_by_access_allowed: true)
    tokens = session.refresh_by_access_payload do
      raise JWTSessions::Errors::Unauthorized, "Somethings not right here!"
    end
    response.set_cookie(JWTSessions.access_cookie,
                        value: tokens[:access],
                        httponly: true,
                        secure: Rails.env.production?)
    render json: { csrf: tokens[:csrf] }
  end
end
Enter fullscreen mode Exit fullscreen mode

Here I’m expecting only expired access tokens to be used for a refresh so within the refresh_by_access_payload method we added an exception. We could do more here like send a notification, flush the session, or ignore it altogether.

The JWT library checks for expiration claims automatically. To avoid the except for an expired access token we can harness the claimless_payload method.

The before_action :authorized_refresh_by_access_request! is used as a protective layer to protect the endpoint.

Update controllers to add access request

Much like Devise’s built-in authorize_user! method we can use one from JWT on our controllers.

# app/controllers/api/v1/artists_controller.rb

module Api
  module V1
    class ArtistsController < ApplicationController
        before_action :authorize_access_request!, except: [:show, :index]
      ...
      end
   end
  end
end
Enter fullscreen mode Exit fullscreen mode

And our records controller:

# app/controllers/api/v1/records_controller.rb

module Api
  module V1
    class RecordsController < ApplicationController
        before_action :authorize_access_request!, except: [:show, :index]
      ...
      end
   end
  end
end
Enter fullscreen mode Exit fullscreen mode

Creating current_user

Again much like Devise we want a helper for the given user who is logged in. We’ll have to establish this ourselves inside the application controller.

# app/controllers/application_controller.rb

class ApplicationController < ActionController::API
  include JWTSessions::RailsAuthorization
  rescue_from JWTSessions::Errors::Unauthorized, with: :not_authorized

  private

  def current_user
    @current_user ||= User.find(payload['user_id'])
  end

  def not_authorized
    render json: { error: 'Not authorized' }, status: :unauthorized
  end
end
Enter fullscreen mode Exit fullscreen mode

Making sure we can authorize certain Cross-Origin requests

Ruby on Rails comes with a cors.rb file within config/initializers/. If you don't see one feel free to create it. Every file within config/initializers gets autoloaded.

Inside that file we can specify specific origins to allow to send/receive requests. Our front-end will run on a different local server so this is where we could pass that. When your app is live you’ll probably point this to a living domain/subdomain.

If you haven’t already, be sure to add/uncomment rack-cors in your Gemfile and run bundle install. Restart your server as well if it is running.

# config/initializers/cors.rb

Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins 'http://localhost:8081'

    resource '*',
      headers: :any,
      credentials: true,
      methods: [:get, :post, :put, :patch, :delete, :options, :head]
  end
end
Enter fullscreen mode Exit fullscreen mode

Your origin will be whatever your frontend port is running on. In my case, it’s 8081. You can comma separate more origins to allow secure access.

Moar Routing!

With all of our endpoints defined we can add those to our routes outside of our API namespaces. My current routes file looks like the following:

Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      resources :artists do
        resources :records
      end
    end
  end

  post 'refresh', controller: :refresh, action: :create
  post 'signin', controller: :signin, action: :create
  post 'signup', controller: :signup, action: :create
  delete 'signin', controller: :signin, action: :destroy
end
Enter fullscreen mode Exit fullscreen mode

We can define the request, controller, name of URL path, and action to fire all in one line of ruby. Love it!

Data

Create some test data in the rails console by running rails c in your terminal. I’ll create a few artists at random just so we have some data to display when testing out our front-end app coming up.

Artist.create!(name: "AC/DC")
Artist.create!(name: "Jimi Hendrix")
Artist.create!(name: "Alice in Chains")
....
# repeat for however many artists you would like to add
Enter fullscreen mode Exit fullscreen mode

The Frontend

Let’s adopt Vue.js for the frontend and tackle that portion of the build. This app will live within the rails app but run separately altogether. Rather than keeping source code separate we can house it within a root folder in our app.

Our toolbox will consist of Node.js, VueJS CLI, Yarn and Axios.

If you’re new to Vue this might be a little overwhelming to grasp at first but it’s quite a convention driven like Rails. The fact that you can sprinkle it throughout any type of app sold me as opposed to frameworks like Angular or React.

At the time of this writing/recording I’m using the following version of node:

$ node -v
v11.4.0
$ yarn -v
1.12.3
Enter fullscreen mode Exit fullscreen mode

Install Vue CLI

$ yarn global add @vue/cli
Enter fullscreen mode Exit fullscreen mode

global means this installs at the system level rather than directly in your project node_modules though still depends on them.

We can check the version of vue to verify install

$ vue --version
2.9.6
Enter fullscreen mode Exit fullscreen mode

Create a new project

cd into your rails app if you haven’t already and run the following:

$ vue init webpack recordstore-front
Enter fullscreen mode Exit fullscreen mode

This will ask a slew of questions. Here are my responses if you’re wanting to follow along:

? Project name recordstore-front
? Project description A Vue.js front-end app for a Ruby on Rails backend app.
? Author Andy Leverenz <andy@web-crunch.com>
? Vue build standalone
? Install vue-router? Yes
? Use ESLint to lint your code? Yes
? Pick an ESLint preset Standard
? Set up unit tests Yes
? Pick a test runner karma
? Setup e2e tests with Nightwatch? No
? Should we run `npm install` for you after the project has been created? (recommended) yarn
Enter fullscreen mode Exit fullscreen mode

Starting the app

$ cd recordstore-front
$ yarn dev
Enter fullscreen mode Exit fullscreen mode

Webpack should do its magic here and you should be able to open your browser to see the new Vue app on localhost:8081

My working directory looks like this:

$ tree . -I "node_modules"
.
├── README.md
├── build
│   ├── build.js
│   ├── check-versions.js
│   ├── logo.png
│   ├── utils.js
│   ├── vue-loader.conf.js
│   ├── webpack.base.conf.js
│   ├── webpack.dev.conf.js
│   ├── webpack.prod.conf.js
│   └── webpack.test.conf.js
├── config
│   ├── dev.env.js
│   ├── index.js
│   ├── prod.env.js
│   └── test.env.js
├── index.html
├── package.json
├── src
│   ├── App.vue
│   ├── assets
│   │   └── logo.png
│   ├── components
│   │   └── HelloWorld.vue
│   ├── main.js
│   └── router
│   └── index.js
├── static
├── test
│   └── unit
│   ├── index.js
│   ├── karma.conf.js
│   └── specs
│   └── HelloWorld.spec.js
└── yarn.lock

10 directories, 25 files
Enter fullscreen mode Exit fullscreen mode

Note : if you want tree to work on your system you’ll need to install it. I used homebrew and ran the following:

$ brew install tree
Enter fullscreen mode Exit fullscreen mode

Add Tailwind CSS

Installing Tailwind CSS

Note: A new Tailwind exists today in beta form. Feel free to use it instead.

I’ve been loving Tailwind so I’m adding it to my project. You can use something more complete like Bootstrap and simply link it via CDN but like I said Tailwind is pretty sweet. I’ll add it with Yarn

$ yarn add tailwindcss --dev
Enter fullscreen mode Exit fullscreen mode

Per the tailwind docs we need to run and init command directly from the node_modules folder

$ ./node_modules/.bin/tailwind init
   tailwindcss 0.7.3
   ✅ Created Tailwind config file: tailwind.js
Enter fullscreen mode Exit fullscreen mode

A tailwind.js file should appear in your project ready to configure.

Add a CSS file

Our CSS will compile down but we need it to have a place for it to do so. In our src directory add a main.css file.

src/
 assets/
 components/
 routes/
 App.vue
 main.js
 main.css
Enter fullscreen mode Exit fullscreen mode

Insie main.css we need the following:

/* recordstore-frontend/src/main.css */

@tailwind preflight;

@tailwind components;

@tailwind utilities;
Enter fullscreen mode Exit fullscreen mode

In main.js add the following

// recordstore-frontend/src/main.js
// The Vue build version to load with the `import` command
// (runtime-only or standalone) has been set in webpack.base.conf with an alias.
import Vue from 'vue'
import App from './App'
import router from './router'
import './main.css'

Vue.config.productionTip = false

/* eslint-disable no-new */
new Vue({
  el: '#app',
  router,
  components: { App },
  template: '<App/>'
})
Enter fullscreen mode Exit fullscreen mode

Almost done we just need to tell our app about tailwind.js

PostCSS config

We need to declare tailwind as a plugin in our .postcss.config.js file and configure purge css as well.

// recordstore-frontend/.postcss.config.js

module.exports = {
  "plugins": {
    "postcss-import": {},
    "tailwindcss": "./tailwind.js",
    "autoprefixer": {}
  }
}
Enter fullscreen mode Exit fullscreen mode

Cleanup

I’ll remove the default HelloWorld component from src/components and the line referencing it inside main.js

Install and Configure Axios

$ yarn add axios vue-axios
Enter fullscreen mode Exit fullscreen mode

Having installed both of those packages I’ll make a home for our axios internals

Create a new folder called backend within src Within that folder create a folder called axios and finally inside that create an index.js file. Here we’ll give axios some global defaults and assign our API URL as a constant which gets used throughout each request.

// recordstore-frontend/src/backend/axios/index.js

import axios from 'axios'

const API_URL = 'http://localhost:3000'

const securedAxiosInstance = axios.create({
  baseURL: API_URL,
  withCredentials: true,
  headers: {
    'Content-Type': 'application/json'
  }
})

const plainAxiosInstance = axios.create({
  baseURL: API_URL,
  withCredentials: true,
  headers: {
    'Content-Type': 'application/json'
  }
})

securedAxiosInstance.interceptors.request.use(config => {
  const method = config.method.toUpperCase()
  if (method !== 'OPTIONS' && method !== 'GET') {
    config.headers = {
      ...config.headers,
      'X-CSRF-TOKEN': localStorage.csrf
    }
  }
  return config
})

securedAxiosInstance.interceptors.response.use(null, error => {
  if (error.response && error.response.config && error.response.status === 401) {
    // If 401 by expired access cookie, we do a refresh request
    return plainAxiosInstance.post('/refresh', {}, { headers: { 'X-CSRF-TOKEN': localStorage.csrf } })
      .then(response => {
        localStorage.csrf = response.data.csrf
        localStorage.signedIn = true
        // After another successfull refresh - repeat original request
        let retryConfig = error.response.config
        retryConfig.headers['X-CSRF-TOKEN'] = localStorage.csrf
        return plainAxiosInstance.request(retryConfig)
      }).catch(error => {
        delete localStorage.csrf
        delete localStorage.signedIn
        // redirect to signin if refresh fails
        location.replace('/')
        return Promise.reject(error)
      })
  } else {
    return Promise.reject(error)
  }
})

export { securedAxiosInstance, plainAxiosInstance }
Enter fullscreen mode Exit fullscreen mode

The gist of what we just did is that axios doesn’t have all the logic we were after. We built two wrappers around axios to get what we desire. We are passing through credentials that check against our CSRF tokens from Rails. In doing so we can establish some logic on if the right criteria are met to log the user in and out, send the right data, and more.

Main Vue configuration

The main.jsfile is our next stop. We’ll import our dependencies and configure a bit more:

// recordstore-frontend/src/main.js

import Vue from 'vue'
import App from './App'
import router from './router'
import VueAxios from 'vue-axios'
import { securedAxiosInstance, plainAxiosInstance } from './backend/axios'
import './main.css' // tailwind

Vue.config.productionTip = false
Vue.use(VueAxios, {
  secured: securedAxiosInstance,
  plain: plainAxiosInstance
})

/* eslint-disable no-new */
new Vue({
  el: '#app',
  router,
  securedAxiosInstance,
  plainAxiosInstance,
  components: { App },
  template: '<App/>'
})
Enter fullscreen mode Exit fullscreen mode

Notice how we make use of VueAxios, and our new secured and plain instances. Think of these as scoped logic which we will use during runtime on our Vue components. You’ll see how this works coming up when we create each component.

Routing on the frontend

I’ll start with the signin component we’ve been building but focus on the front-end routing using Vue router.

// recordstore-frontend/router/index.js

import Vue from 'vue'
import Router from 'vue-router'
import Signin from '@/components/Signin'

Vue.use(Router)

export default new Router({
  routes: [
    {
      path: '/',
      name: 'Signin',
      component: Signin
    }
  ]
})
Enter fullscreen mode Exit fullscreen mode

Build the Signin Vue Component

<!-- recordstore-frontend/src/components/Signin.vue -->

<template>
  <div class="max-w-sm m-auto my-8">
    <div class="border p-10 border-grey-light shadow rounded">
      <h3 class="text-2xl mb-6 text-grey-darkest">Sign In</h3>
      <form @submit.prevent="signin">
        <div class="text-red" v-if="error">{{ error }}</div>

        <div class="mb-6">
          <label for="email" class="label">E-mail Address</label>
          <input type="email" v-model="email" class="input" id="email" placeholder="andy@web-crunch.com">
        </div>
        <div class="mb-6">
          <label for="password" class="label">Password</label>
          <input type="password" v-model="password" class="input" id="password" placeholder="Password">
        </div>
        <button type="submit" class="font-sans font-bold px-4 rounded cursor-pointer no-underline bg-green hover:bg-green-dark block w-full py-4 text-white items-center justify-center">Sign In</button>

        <div class="my-4"><router-link to="/signup" class="link-grey">Sign up</router-link></div>
      </form>
    </div>
  </div>
</template>

<script>
export default {
  name: 'Signin',
  data () {
    return {
      email: '',
      password: '',
      error: ''
    }
  },
  created () {
    this.checkSignedIn()
  },
  updated () {
    this.checkSignedIn()
  },
  methods: {
    signin () {
      this.$http.plain.post('/signin', { email: this.email, password: this.password })
        .then(response => this.signinSuccessful(response))
        .catch(error => this.signinFailed(error))
    },
    signinSuccessful (response) {
      if (!response.data.csrf) {
        this.signinFailed(response)
        return
      }
      localStorage.csrf = response.data.csrf
      localStorage.signedIn = true
      this.error = ''
      this.$router.replace('/records')
    },
    signinFailed (error) {
      this.error = (error.response && error.response.data && error.response.data.error) || ''
      delete localStorage.csrf
      delete localStorage.signedIn
    },
    checkSignedIn () {
      if (localStorage.signedIn) {
        this.$router.replace('/records')
      }
    }
  }
}
</script>
Enter fullscreen mode Exit fullscreen mode

This component is a basic login form with a link to our sign up form if you don’t already have an account. We leverage Tailwind for styles and Vue for functionality. In the script block I check if the user is already signed in upon component creation if so they will redirect to /records and if not they’ll see this form. Our actual signin method performs a post request when the form submission is triggered.

Signup Component

<!-- recordstore-frontend/src/components/Signup.vue -->

<template>
  <div class="max-w-sm m-auto my-8">
    <div class="border p-10 border-grey-light shadow rounded">
      <h3 class="text-2xl mb-6 text-grey-darkest">Sign Up</h3>
      <form @submit.prevent="signup">
        <div class="text-red" v-if="error">{{ error }}</div>

        <div class="mb-6">
          <label for="email" class="label">E-mail Address</label>
          <input type="email" v-model="email" class="input" id="email" placeholder="andy@web-crunch.com">
        </div>

        <div class="mb-6">
          <label for="password" class="label">Password</label>
          <input type="password" v-model="password" class="input" id="password" placeholder="Password">
        </div>

        <div class="mb-6">
          <label for="password_confirmation" class="label">Password Confirmation</label>
          <input type="password" v-model="password_confirmation" class="input" id="password_confirmation" placeholder="Password Confirmation">
        </div>
        <button type="submit" class="font-sans font-bold px-4 rounded cursor-pointer no-underline bg-green hover:bg-green-dark block w-full py-4 text-white items-center justify-center">Sign Up</button>

        <div class="my-4"><router-link to="/" class="link-grey">Sign In</router-link></div>
      </form>
    </div>
  </div>
</template>

<script>
export default {
  name: 'Signup',
  data () {
    return {
      email: '',
      password: '',
      password_confirmation: '',
      error: ''
    }
  },
  created () {
    this.checkedSignedIn()
  },
  updated () {
    this.checkedSignedIn()
  },
  methods: {
    signup () {
      this.$http.plain.post('/signup', { email: this.email, password: this.password, password_confirmation: this.password_confirmation })
        .then(response => this.signupSuccessful(response))
        .catch(error => this.signupFailed(error))
    },
    signupSuccessful (response) {
      if (!response.data.csrf) {
        this.signupFailed(response)
        return
      }

      localStorage.csrf = response.data.csrf
      localStorage.signedIn = true
      this.error = ''
      this.$router.replace('/records')
    },
    signupFailed (error) {
      this.error = (error.response && error.response.data && error.response.data.error) || 'Something went wrong'
      delete localStorage.csrf
      delete localStorage.signedIn
    },
    checkedSignedIn () {
      if (localStorage.signedIn) {
        this.$router.replace('/records')
      }
    }
  }
}
</script>
Enter fullscreen mode Exit fullscreen mode

Much of the logic is the same for the Signup.vue component. Here we introduce a new field and different POST route on the signup path. This points to /signup on our rails app as defined in config/routes.rb.

Header.vue component

I want to have a global header component above our router. In doing so we need to import that into our main App.vue file. In the end the Header.vue file looks like the following:

<!-- recordstore-frontend/src/components/Header.vue -->

<template>
  <header class="bg-grey-lighter py-4">
    <div class="container m-auto flex flex-wrap items-center justify-end">
      <div class="flex-1 flex items-center">
        <svg class="fill-current text-indigo" viewBox="0 0 24 24" width="24" height="24"><title>record vinyl</title><path d="M23.938 10.773a11.915 11.915 0 0 0-2.333-5.944 12.118 12.118 0 0 0-1.12-1.314A11.962 11.962 0 0 0 12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12c0-.414-.021-.823-.062-1.227zM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm0-5a1 1 0 1 0 0 2 1 1 0 0 0 0-2z"></path></svg>

        <a href="/" class="uppercase text-sm font-mono pl-4 font-semibold no-underline text-indigo-dark hover:text-indigo-darker">Record Store</a>
      </div>
      <div>
        <router-link to="/" class="link-grey px-2 no-underline" v-if="!signedIn()">Sign in</router-link>
        <router-link to="/signup" class="link-grey px-2 no-underline" v-if="!signedIn()">Sign Up</router-link>
        <router-link to="/records" class="link-grey px-2 no-underline" v-if="signedIn()">Records</router-link>
        <router-link to="/artists" class="link-grey px-2 no-underline" v-if="signedIn()">Artists</router-link>
        <a href="#" @click.prevent="signOut" class="link-grey px-2 no-underline" v-if="signedIn()">Sign out</a>
      </div>
    </div>
  </header>
</template>

<script>
export default {
  name: 'Header',
  created () {
    this.signedIn()
  },
  methods: {
    setError (error, text) {
      this.error = (error.response && error.response.data && error.response.data.error) || text
    },
    signedIn () {
      return localStorage.signedIn
    },
    signOut () {
      this.$http.secured.delete('/signin')
        .then(response => {
          delete localStorage.csrf
          delete localStorage.signedIn
          this.$router.replace('/')
        })
        .catch(error => this.setError(error, 'Cannot sign out'))
    }
  }
}
</script>
Enter fullscreen mode Exit fullscreen mode

This file get’s imported here:

<!-- src/components/App.vue-->
<template>
  <div id="app">
    <Header/>
    <router-view></router-view>
  </div>
</template>

<script>
import Header from './components/Header.vue'

export default {
  name: 'App',
  components: {
    Header
  }
}
</script>
Enter fullscreen mode Exit fullscreen mode

Artists

We have data already in the database so let’s start with our Artists.vue component

<!-- recordstore-frontend/src/components/artists/Artists.vue -->

<template>
  <div class="max-w-md m-auto py-10">
    <div class="text-red" v-if="error">{{ error }}</div>
    <h3 class="font-mono font-regular text-3xl mb-4">Add a new artist</h3>
    <form action="" @submit.prevent="addArtist">
      <div class="mb-6">
        <input class="input"
          autofocus autocomplete="off"
          placeholder="Type an arist name"
          v-model="newArtist.name" />
      </div>
      <input type="submit" value="Add Artist" class="font-sans font-bold px-4 rounded cursor-pointer no-underline bg-green hover:bg-green-dark block w-full py-4 text-white items-center justify-center" />
    </form>

    <hr class="border border-grey-light my-6" />

    <ul class="list-reset mt-4">
      <li class="py-4" v-for="artist in artists" :key="artist.id" :artist="artist">

        <div class="flex items-center justify-between flex-wrap">
          <p class="block flex-1 font-mono font-semibold flex items-center ">
            <svg class="fill-current text-indigo w-6 h-6 mr-2" viewBox="0 0 20 20" width="20" height="20"><title>music artist</title><path d="M15.75 8l-3.74-3.75a3.99 3.99 0 0 1 6.82-3.08A4 4 0 0 1 15.75 8zm-13.9 7.3l9.2-9.19 2.83 2.83-9.2 9.2-2.82-2.84zm-1.4 2.83l2.11-2.12 1.42 1.42-2.12 2.12-1.42-1.42zM10 15l2-2v7h-2v-5z"></path></svg>
            {{ artist.name }}
          </p>

          <button class="bg-tranparent text-sm hover:bg-blue hover:text-white text-blue border border-blue no-underline font-bold py-2 px-4 mr-2 rounded"
          @click.prevent="editArtist(artist)">Edit</button>

          <button class="bg-transprent text-sm hover:bg-red text-red hover:text-white no-underline font-bold py-2 px-4 rounded border border-red"
         @click.prevent="removeArtist(artist)">Delete</button>
        </div>

        <div v-if="artist == editedArtist">
          <form action="" @submit.prevent="updateArtist(artist)">
            <div class="mb-6 p-4 bg-white rounded border border-grey-light mt-4">
              <input class="input" v-model="artist.name" />
              <input type="submit" value="Update" class=" my-2 bg-transparent text-sm hover:bg-blue hover:text-white text-blue border border-blue no-underline font-bold py-2 px-4 rounded cursor-pointer">
            </div>
          </form>
        </div>
      </li>
    </ul>
  </div>
</template>

<script>
export default {
  name: 'Artists',
  data () {
    return {
      artists: [],
      newArtist: [],
      error: '',
      editedArtist: ''
    }
  },
  created () {
    if (!localStorage.signedIn) {
      this.$router.replace('/')
    } else {
      this.$http.secured.get('/api/v1/artists')
        .then(response => { this.artists = response.data })
        .catch(error => this.setError(error, 'Something went wrong'))
    }
  },
  methods: {
    setError (error, text) {
      this.error = (error.response && error.response.data && error.response.data.error) || text
    },
    addArtist () {
      const value = this.newArtist
      if (!value) {
        return
      }
      this.$http.secured.post('/api/v1/artists/', { artist: { name: this.newArtist.name } })

        .then(response => {
          this.artists.push(response.data)
          this.newArtist = ''
        })
        .catch(error => this.setError(error, 'Cannot create artist'))
    },
    removeArtist (artist) {
      this.$http.secured.delete(`/api/v1/artists/${artist.id}`)
        .then(response => {
          this.artists.splice(this.artists.indexOf(artist), 1)
        })
        .catch(error => this.setError(error, 'Cannot delete artist'))
    },
    editArtist (artist) {
      this.editedArtist = artist
    },
    updateArtist (artist) {
      this.editedArtist = ''
      this.$http.secured.patch(`/api/v1/artists/${artist.id}`, { artist: { title: artist.name } })
        .catch(error => this.setError(error, 'Cannot update artist'))
    }
  }
}
</script>
Enter fullscreen mode Exit fullscreen mode

This component is responsible for a few things. I realize this could be condensed down further to multiple components but for the sake of time, I contained everything. In this file, we have a form, a listing of artists, and an update form when editing an artist. We’ll loop through the data from our Rails app to display data in the database and use Vue to perform basic CRUD operations with JavaScript and Axios.

Note how I point to api/v1/artists in a lot of axios requests. This is the namespace in full effect we created prior on the rails application. Cool stuff!

The Records.vue component

<!-- recordstore-frontend/src/components/artists/Records.vue -->

<template>
  <div class="max-w-md m-auto py-10">
    <div class="text-red" v-if="error">{{ error }}</div>
    <h3 class="font-mono font-regular text-3xl mb-4">Add a new record</h3>
    <form action="" @submit.prevent="addRecord">
      <div class="mb-6">
        <label for="record_title" class="label">Title</label>
        <input
          id="record_title"
          class="input"
          autofocus autocomplete="off"
          placeholder="Type a record name"
          v-model="newRecord.title" />
      </div>

      <div class="mb-6">
        <label for="record_year" class="label">Year</label>
        <input
          id="record_year"
          class="input"
          autofocus autocomplete="off"
          placeholder="Year"
          v-model="newRecord.year"
        />
       </div>

      <div class="mb-6">
        <label for="artist" class="label">Artist</label>
        <select id="artist" class="select" v-model="newRecord.artist">
          <option disabled value="">Select an artist</option>
          <option :value="artist.id" v-for="artist in artists" :key="artist.id">{{ artist.name }}</option>
        </select>
        <p class="pt-4">Don't see an artist? <router-link class="text-grey-darker underline" to="/artists">Create one</router-link></p>
       </div>

      <input type="submit" value="Add Record" class="font-sans font-bold px-4 rounded cursor-pointer no-underline bg-green hover:bg-green-dark block w-full py-4 text-white items-center justify-center" />
    </form>

    <hr class="border border-grey-light my-6" />

    <ul class="list-reset mt-4">
      <li class="py-4" v-for="record in records" :key="record.id" :record="record">

        <div class="flex items-center justify-between flex-wrap">
          <div class="flex-1 flex justify-between flex-wrap pr-4">
            <p class="block font-mono font-semibold flex items-center">
              <svg class="fill-current text-indigo w-6 h-6 mr-2" viewBox="0 0 24 24" width="24" height="24"><title>record vinyl</title><path d="M23.938 10.773a11.915 11.915 0 0 0-2.333-5.944 12.118 12.118 0 0 0-1.12-1.314A11.962 11.962 0 0 0 12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12c0-.414-.021-.823-.062-1.227zM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm0-5a1 1 0 1 0 0 2 1 1 0 0 0 0-2z" ></path></svg>
              {{ record.title }} &mdash; {{ record.year }}
            </p>
            <p class="block font-mono font-semibold">{{ getArtist(record) }}</p>
          </div>
          <button class="bg-transparent text-sm hover:bg-blue hover:text-white text-blue border border-blue no-underline font-bold py-2 px-4 mr-2 rounded"
          @click.prevent="editRecord(record)">Edit</button>

          <button class="bg-transparent text-sm hover:bg-red text-red hover:text-white no-underline font-bold py-2 px-4 rounded border border-red"
         @click.prevent="removeRecord(record)">Delete</button>
        </div>

        <div v-if="record == editedRecord">
          <form action="" @submit.prevent="updateRecord(record)">
            <div class="mb-6 p-4 bg-white rounded border border-grey-light mt-4">

              <div class="mb-6">
                <label class="label">Title</label>
                <input class="input" v-model="record.title" />
              </div>

              <div class="mb-6">
                <label class="label">Year</label>
                <input class="input" v-model="record.year" />
              </div>

              <div class="mb-6">
                <label class="label">Artist</label>
                <select id="artist" class="select" v-model="record.artist">
                  <option :value="artist.id" v-for="artist in artists" :key="artist.id">{{ artist.name }}</option>
                </select>
              </div>

              <input type="submit" value="Update" class="bg-transparent text-sm hover:bg-blue hover:text-white text-blue border border-blue no-underline font-bold py-2 px-4 mr-2 rounded">
            </div>
          </form>
        </div>
      </li>
    </ul>
  </div>
</template>

<script>
export default {
  name: 'Records',
  data () {
    return {
      artists: [],
      records: [],
      newRecord: [],
      error: '',
      editedRecord: ''
    }
  },
  created () {
    if (!localStorage.signedIn) {
      this.$router.replace('/')
    } else {
      this.$http.secured.get('/api/v1/records')
        .then(response => { this.records = response.data })
        .catch(error => this.setError(error, 'Something went wrong'))

      this.$http.secured.get('/api/v1/artists')
        .then(response => { this.artists = response.data })
        .catch(error => this.setError(error, 'Something went wrong'))
    }
  },
  methods: {
    setError (error, text) {
      this.error = (error.response && error.response.data && error.response.data.error) || text
    },
    getArtist (record) {
      const recordArtistValues = this.artists.filter(artist => artist.id === record.artist_id)
      let artist

      recordArtistValues.forEach(function (element) {
        artist = element.name
      })

      return artist
    },
    addRecord () {
      const value = this.newRecord
      if (!value) {
        return
      }
      this.$http.secured.post('/api/v1/records/', { record: { title: this.newRecord.title, year: this.newRecord.year, artist_id: this.newRecord.artist } })

        .then(response => {
          this.records.push(response.data)
          this.newRecord = ''
        })
        .catch(error => this.setError(error, 'Cannot create record'))
    },
    removeRecord (record) {
      this.$http.secured.delete(`/api/v1/records/${record.id}`)
        .then(response => {
          this.records.splice(this.records.indexOf(record), 1)
        })
        .catch(error => this.setError(error, 'Cannot delete record'))
    },
    editRecord (record) {
      this.editedRecord = record
    },
    updateRecord (record) {
      this.editedRecord = ''
      this.$http.secured.patch(`/api/v1/records/${record.id}`, { record: { title: record.title, year: record.year, artist_id: record.artist } })
        .catch(error => this.setError(error, 'Cannot update record'))
    }
  }
}
</script>
Enter fullscreen mode Exit fullscreen mode

The Records.vue component is quite similar to the Artists.vue component in that the same basic CRUD operations are in full effect. I introduce the artist to record relation with a new select field which grabs data from our backend and saves it once a new record is saved. We loop through both Record and Artist data to get the necessary ids and fields back to save, edit, update and delete the fields correctly.

Where to go next?

Our app is far from complete but it is functioning nicely. We have JWT-based authentication and a full CRUD based Vue app working on the frontend. Our backend is talking to the frontend the way we intended🎉. I found one final bug in my Rails artists_controller.rb and records_controller.rb files that dealt with the location: property. Normally those would exist but I have removed them due to an odd namespacing issue I can’t quite figure out. Maybe you know the solution?

From here I invite you to extend the app and/or use it as a guide in your own projects. I learned a lot with this build. I have to admit, this was the hardest one I’ve taken on thus far. Hopefully, it’s enough to show you a new way to use Ruby on Rails with modern frontend frameworks and more.

The Series so far

Shameless plug time

I have a new course called Hello Rails. Hello Rails is a modern course designed to help you start using and understanding Ruby on Rails fast. If you’re a novice when it comes to Ruby or Ruby on Rails I invite you to check out the site. The course will be much like these builds but a super more in-depth version with more realistic goals and deliverables. 💌 Get notified!

Follow @hello_rails and myself @justalever on Twitter.

The post Ruby on Rails API with Vue.js appeared first on Web-Crunch.

Top comments (5)

Collapse
 
andrewbrown profile image
Andrew Brown 🇨🇦

A Suggestion About Controllers

I would suggest the following refactors:

  • namespacing on one line
  • create an inherit for a base controller for future namespaces
  • use %w for shorter sytnax
  • name the instance models as model so you have more agitiliy with future renaming
  • use fetch instead of require
class Api::V1::ArtistsController < Api::V1::BaseController
  before_action :find_model, only: %w(show update destroy)

  def index
    @artists = Artist.all
    render json: @artists
  end

  def show
    render json: @artist
  end

  def create
    @artist = Artist.new(params_model)
    if @artist.save
      render json: @artist, status: :created
    else
      render json: @artist.errors, status: :unprocessable_entity
    end
  end

  def update
    if @artist.update(params_model)
      render json: @artist
    else
      render json: @artist.errors, status: :unprocessable_entity
    end
  end

  def destroy
    @artist.destroy
  end

  protected
  def find_model
    @artist = Artist.find(params[:id])
  end

  def params_model
    params.fetch(:artist,{}).permit(:name)
  end
end
Enter fullscreen mode Exit fullscreen mode

Some of the things I suggest above allows you to then conventional your controllers further which helps you avoid the many abstractions that are wrongly and commonly adopted in Rails applications.

So here pulled from one of my many code bases below I show you this conventialize base controller. When you are tasked with building many Rails apps you may come to the same natural conclusion I am showing and will find that the majority of web-apps can fit within this structure allowing for rapid development of insane speeds.

class Admin::Api::BaseController < Admin::BaseController
  before_filter :find_model, only: %i(show edit update destroy)
  def index
    klass = self.controller_name.classify.constantize
    render_paginated_json klass, :admin_index_json, index_params
  end

  def new
    klass = chain
    render json: klass.admin_new_json
  end

  def show
    render json: @model.admin_show_json
  end

  def create
    @model = chain.create params_model
    render_json
  end

  def edit
    render json: @model.admin_edit_json
  end

  def update
    @model.update_attributes(params_model)
    render_json
  end

  def destroy
    @model.destroy
    render json: {id: @model.id}
  end

  protected
    def render_json
      if @model.errors.any?
        render json: @model.errors.to_json, status: 422
      else
        render json: @model.admin_object_json
      end
    end

    def chain
      self.controller_name.classify.constantize
    end

    def find_model
      @model = chain.find params[:id]
    end
end

Enter fullscreen mode Exit fullscreen mode

So the result that occurs is you have controllers that contain the real code and nothing more.

class Admin::Api::CurriculumsController < Admin::Api::BaseController
  protected
    def model_params
      params.fetch(:curriculum,{})
        .permit :pdf,
                :course_id
    end
end
Enter fullscreen mode Exit fullscreen mode
class Admin::Api::MarksController < Admin::Api::BaseController
end
Enter fullscreen mode Exit fullscreen mode

This then opens up your code base to create powerful generators because things are so highly conventialize. When I use to run my own dev-firm we could generate out 80% of a web-app and have an MVP for delivery in two days.

Thoughbot had something similar but not to this level of exterme generation.

A bit of rant but food for thought for thoes getting into Rails.

Collapse
 
zimski profile image
CHADDA Chakib

Interesting point.

How Pushing all the CRUD logic in a base class make this easier/faster than just scafolding models + controllers ?

Collapse
 
zimski profile image
CHADDA Chakib

Hey thanks for your work.

Can't imagine how long that's take you to do all this.

Great work, keep going

Collapse
 
justalever profile image
Andy Leverenz

It truly takes a long time. I love teaching and building though. Glad you enjoyed it!

Collapse
 
zaekof profile image
NOGIER Loïc

Thanks man, great post!