January 25, 2019
•Last updated November 5, 2023
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
Rails 5.2.2
Ruby 2.5
- Gem
bcrypt 3.1.7
- Gem
rack-cors
- Gem
redis 4.1.0
- Gem
jwt-sessions
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
Add gems
- Uncomment
rack-cors
andbcrypt
. - add
redis
andjwt_sessions
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]
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
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
Let's migrate that in
$ rails db:migrate
Update the user model to use the method has_secure_password
which is supplied by the bcrypt gem.
# app/models/user.rb
class User < ApplicationRecord
has_secure_password
end
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
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.
$ rails g scaffold Record title year artist:references user:references
Migrate both models in
$ rails db:migrate
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
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
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
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
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
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
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'
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
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
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
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
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
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
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
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
Making sure we can authorize certain Cross-Origin requests
Rails comes with a cors.rb
file within config/initializers/
. There 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
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
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
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
Install Vue CLI
$ yarn global add @vue/cli
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
Create a new project
cd
into your rails app if you haven't already and run the following:
$ vue init webpack recordstore-front
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 <[email protected]>
? 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
Starting the app
$ cd recordstore-front
$ yarn dev
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
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
Add Tailwind CSS
Installing Tailwind CSS
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
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
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
Insie main.css
we need the following:
/* recordstore-frontend/src/main.css */
@tailwind preflight;
@tailwind components;
@tailwind utilities;
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/>'
})
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": {}
}
}
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
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 }
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.js
file 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/>'
})
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
}
]
})
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="[email protected]">
</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>
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="[email protected]">
</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>
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>
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>
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>
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 }} — {{ 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>
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 intend. 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 from 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
- Letβs Build: With Ruby on Rails β Introduction
- Letβs Build: With Ruby on Rails β Installation
- Letβs Build: With Ruby on Rails β Blog with Comments
- Letβs Build: With Ruby on Rails β A Twitter Clone
- Letβs Build: With Ruby on Rails β A Dribbble Clone
- Letβs Build: With Ruby on Rails β Project Management App
- Letβs Build: With Ruby on Rails β Discussion Forum
- Letβs Build: With Ruby on Rails β Deploying an App to Heroku
- Letβs Build: With Ruby on Rails β eCommerce Music Shop
- Letβs Build: With Ruby on Rails β Book Library App with Stripe Subscription Payments
- Letβs Build: With Ruby on Rails β Trade App With In-App Messaging
- Letβs Build: With Ruby on Rails β Multitenancy Workout Tracker App
- Letβs Build: With Ruby on Rails β Scheduling App with Payments
Shameless plug time
I have a new course called Hello Rails. Hello Rails is 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. View the course!
Follow @hello_rails and myself @justalever on Twitter.
Categories
Collection
Part of the Let's Build: With Ruby on Rails collection
Products and courses
-
Hello Hotwire
A course on Hotwire + Ruby on Rails.
-
Hello Rails
A course for newcomers to Ruby on Rails.
-
Rails UI
UI templates and components for Rails.