loading...

Vue SPA with Rails JSON:API using Graphiti

mikeeus profile image Mikias Abera ・6 min read

Standards are good, they make our lives easier. Graphiti and it's client library Spraypaint make it easy to build JSON:API compliant APIs that integrate seamlessly with front-end frameworks like Vue.

I’m using graphiti in a production application to serve JSON requests to Vue components embedded in our Rails views. It's been reliable, flexible and a pleasure to use.

In this tutorial we’ll walk through setting up Vue as an SPA with a JSON:API compliant Rails 5 API using graphiti. You can clone the demo app to see the finished product.

# follow along
git clone git@github.com:mikeeus/demos-rails-webpack.git
cd demos-rails-webpack
git checkout ma-vue-graphiti

Set up Rails API with Webpacker

Create Rails app with webpacker and Vue. I use postgresql but you can use any database you like.

mkdir rails-vue
rails new . --webpack=vue —database=postgresql
rails db:create db:migrate

And… done! That was easy right? Now we can move on to setting up graphiti to handle parsing and serializing our records according to the JSON API spec.

Set up Graphiti

Install graphiti, you can find the full instructions in the docs. We’ll need to add the following gems.

# The only strictly-required gem
gem 'graphiti'

# For automatic ActiveRecord pagination
gem 'kaminari'

# Test-specific gems
group :development, :test do
  gem 'rspec-rails'
  gem 'factory_bot_rails'
  gem 'faker'
  gem 'graphiti_spec_helpers'
end

group :test do
  gem 'database_cleaner'
end

We’ll need to add Graphiti::Rails to our Application controller so graphiti can handle parsing and serializing our requests. And we’ll register the Graphiti::Errors::RecordNotFound exception so we can return 404.

# app/application_controller.rb
class ApplicationController < ActionController::API
    include Graphiti::Rails

    # When #show action does not find record, return 404
    register_exception Graphiti::Errors::RecordNotFound, status: 404

    rescue_from Exception do |e|
        handle_exception(e)
    end
end

Now lets create a Post model.

rails g model Post title:string content:string
rails db:migrate

We’ll also need to create a PostResource for graphiti and a controller to handle requests. Graphiti has a generator that makes it easy to set this up.

rails g graphiti:resource Post -a index

We’re going to declare our attributes and add ActionView::Helpers::TextHelper to format our Post content using simple_format so we can render it nicely on our client.

class PostResource < Graphiti::Resource
    include ActionView::Helpers::TextHelper

    self.adapter = Graphiti::Adapters::ActiveRecord
    primary_endpoint '/api/v1/posts'

    attribute :title, :string
    attribute :content, :string do
        simple_format(@object.content)
    end
end

The generator will also create specs and a controller at app/controllers/posts_controller.rb. We’re going to move that to a namespaced folder app/api/v1 which will allow us to manage API versions in the future.

# app/controllers/api/v1/posts_controller.rb
module Api
    module V1
        class PostsController < ApplicationController
            def index
                posts = PostResource.all(params)
                render jsonapi: posts
            end
        end
    end
end

We use render jsonapi: posts to render the posts according to the JSON:API spec so we can parse it on our client using graphiti's js client spraypaint.

Now let’s add the route.

# config/routes.rb
Rails.application.routes.draw do
    namespace :api do
        namespace :v1 do
            resources :posts, only: :index
        end
    end
end

Alright looking good! All we need now is a client to consume our API.

Setup Vue

Webpacker comes with a generator for vue which you can see in the docs. It makes it super easy to add Vue or any other front-end framework like React or Angular to our application.

bundle exec rails webpacker:install:vue

Running the above will generate files at app/javascript

We’re going to edit app/javascript/packs/application.js so that we can render our App.vue component.

// app/javascript/packs/application.js

import Vue from 'vue/dist/vue.esm'
import App from '../app.vue'

document.addEventListener('DOMContentLoaded', () => {
    const app = new Vue({
        el: '#app',
        components: { App }
    })
})

For now we can disregard the Vue component, we’ll fill it in later once we’ve setup our resources and endpoints.

Setup Rails to Serve Static Content

We can’t use our ApplicationController to serve our index.html page since it inherits from ActionController::Api and we want to keep it that way since our other controllers will inherit from it.

In order to serve our index page for the SPA, we’ll use a PagesController that inherits from ActionController::Base so it can serve html files without issue.

# app/pages_controller.rb
class PagesController < ActionController::Base
  def index
  end
end

Next we’ll add a route for our homepage and redirect all 404 requests to it so our SPA can take care of business.

# config/routes.rb
Rails.application.routes.draw do
    root 'pages#index'
    get '404', to: 'pages#index'

    namespace :api do
        # ...
    end
end

Looking good, friends! Now let’s add our index.html page which will render our Vue component.

# app/views/pages/index.html
<%= javascript_pack_tag 'application' %>
<%= stylesheet_pack_tag 'application' %>

<div id="app">
  <app></app>
</div>

It’s super simple: it just pulls in our javascript and stylesheets compiled by webpacker. Then we add a div with id=“app” and a nested <app></app> so our Vue wrapper component can pick it up and render the main component.

This is the only Rails view we need to write for our application to work.

Create Models on Client

Usually when I build an SPA I’ll write services that use libraries like axios to make Ajax requests to the backend. Graphiti comes with a client library called spraypaint that handles parsing and serializing JSON:API payloads. It supports including associations, advanced filtering, sorting, statistics and more.

Let’s set it up!

yarn add spraypaint isomorphic-fetch

Next let’s create an ApplicationRecord class that will store our spraypaint configuration.

// app/javascript/models/application_record.js

import { SpraypaintBase } from 'spraypaint';

export const ApplicationRecord = SpraypaintBase.extend({
  static: {
    baseUrl: '',
    apiNamespace: '/api/v1',
  }
})

We set the baseUrl and apiNamespace to ‘’ and ‘/api/v1‘ respectively so that spraypaint uses relative paths and avoids CORS requests. It also namespaces our requests so we can manage API versions easily.

Now the Post model

// app/javascript/models/post.model.js

import { ApplicationRecord } from './application_record';

export const Post = ApplicationRecord.extend({
    static: {
        jsonapiType: 'posts'
    },

    attrs: {
        id: attr(),
        title: attr(),
        content: attr()
    },

    methods: {
        preview() {
            return this.content.slice(0, 50).trim() + '...'
        }
    }
})

We declare the id, title and content attributes. We also add a method to return a truncated preview of the content to show how we declare methods.

The jsonapiType property is needed to generate the endpoint and parse and serialize the JSON payload.

Now we’re ready to hook up the client to the API.

Hook up SPA

To hook everything up we’ll create a Vue component that uses our spraypaint models to communicate with our endpoints.

// app/javascript/app.vue

<template>
<div>
    <h1>Posts</h1>
    <div v-if="error" class="error">{{error}}</div>
    <div class="loading" v-if="loading">Loading...</div>
    <ul>
        <li v-for="post of posts" :key="post.id">
            <h3>{{post.title}}</h3>
            <p v-html="post.preview()"></p>
        </li>
    </ul>
</div>
</template>

<script>
import {Post} from './models/post.model'

export default {
    data: function () {
        return {
            posts: [],
            error: null,
            loading: true
        }
    },

    created() {
        Post.all()
            .then(res => {
                this.posts = res.data
                this.loading = false
            })
            .catch(err => {
                this.error = err
                this.loading = false
            })
        }
}
</script>

<style scoped>
h1 {
    text-align: center;
}
ul {
    list-style: none;
}
</style>

Marvellous! If we add some posts in the console and run the application we will see the posts load and render in the page.

Notice that we import our Post model and use it in our created() call like it was a Rails model. Calling Post.all() returns a promise that we can chain to set our posts and loading data properties. The spraypaint model can chain more useful methods like where and page.

Post.where({ search: 'excerpt' })
    .stats({ total: 'count' })
    .page(1)
    .per(10)
    .order({ created_at: 'desc' })
    .all()
    .then(res => ...)

Spraypaint is a very powerful library that supports pagination, sorting, statistics, complex filtering and much more. You can check out the spraypaint docs for detailed guides.

Conclusion

Standards are good. Vue is awesome, and so is Rails. Gems and libraries like Graphiti and Spraypaint make it super easy to build scalable REST APIs that comply with said standards and integrate seamlessly with frameworks like Vue.

I hope you enjoyed the article, don’t forget to like if you did. I would love to hear your thoughts or suggestions for other articles. Just leave a comment below :)

Discussion

pic
Editor guide
Collapse
sagar2agrawal profile image
Sagar Agrawal

Hello,

so I have been working on vue and jsorm/spraypaint for some time and i am stuck with, like how to make a fetch request instead of Post and send some metadata also with it.

like send data to customer/:id, where the id would be fetched from the url and send as meta data, while secrets from the URL to be send as normal data.

import { ApplicationRecord } from './application_record';

export const Post = ApplicationRecord.extend({
static: {
jsonapiType: 'posts/:id'
},

attrs: {
    id: attr(),
    title: attr(),
    content: attr()
},

methods: {
    preview() {
        return this.content.slice(0, 50).trim() + '...'
    }
}

})