Last reviewed in April 2018 with Ember 3.1
The JSON API specification dovetails nicely with the Ember and Rails philosophy of convention over configuration.
Our plan is to put it to work. We'll build a full-blown Ember app hooked to a Rails 5 API using JSON API, step by step.
We will create a simple bookstore.
Authors write books. They are published by either a publishing house or the author himself (self-published).
The bookstore sells books and makes money on discounts offered by publishers. Authors who self-publish offer a fixed discount of 10%.
Make sure you are running Ruby 2.2.2 or newer: that is what Rails 5 requires. Let's install it.
$ ruby -v
ruby 2.3.5p376 (2017-09-14 revision 59905) [x86_64-darwin16]
$ bundler -v
Bundler version 1.16.1
$ gem install rails
# ...
18 gems installed
Note down the Rails version (5.2.0
in our case). It's possible we have multiple gems if Rails was previously installed in our system. We will create our backend API project ensuring it's Rails 5:
$ rails _5.2.0_ new bookstore-api --api
Let's pick a name and create our shiny new Ember app.
$ ember new bookstore
Now that we have both apps ready, it's time to define and generate our models.
Book
attributes: title:string
, price:decimal
, author:belongs-to
, publisher:belongs-to(polymorphic)
PublishingHouse
attributes: name:string
, discount:decimal
, published-books:has-many
Author
attributes: name:string
, books:has-many
, published-books:has-many
Publisher
is the polymorphic type of PublishingHouse
and Author
Using the Rails generator we'll scaffold required files and migrate the database to those changes. Inside the Rails project directory:
$ bin/rails generate scaffold book title 'price:decimal{5,2}' author:references publisher:references{polymorphic}
$ bin/rails g scaffold author name
$ bin/rails g scaffold publishing_house name 'discount:decimal{2,2}'
$ bin/rails db:migrate
All this is fancy but we have no data to interact with…
Sample data can be provided in the db/seeds.rb
file.
# db/seeds.rb
pub1 = PublishingHouse.create(name: "ABC Publisher", discount: 40)
pub2 = PublishingHouse.create(name: "Acme Publishing House", discount: 50)
pub3 = PublishingHouse.create(name: "Foobar Corporation", discount: 55)
author1 = Author.create(name: "James Jackson")
author2 = Author.create(name: "Roberta Rock")
author3 = Author.create(name: "Daniel Duck")
author4 = Author.create(name: "Amanda Djidjinski")
author5 = Author.create(name: "Zoe Zack")
author6 = Author.create(name: "Bill Burray")
author7 = Author.create(name: "Charlie Chuck")
book1 = Book.create(title: "The Great Escape", author: author7, publisher: pub1, price: 24.20)
book2 = Book.create(title: "Saving Myself", author: author6, publisher: pub1, price: 14.13)
book3 = Book.create(title: "The Killer Doctors", author: author5, publisher: pub1, price: 15.12)
book4 = Book.create(title: "Marianne", author: author4, publisher: pub1, price: 10.50)
book5 = Book.create(title: "On the Verge of Salvation", author: author4, publisher: pub2, price: 11.76)
book6 = Book.create(title: "Fields of L", author: author3, publisher: pub2, price: 27.87)
book7 = Book.create(title: "Waterfront", author: author2, publisher: pub2, price: 11.97)
book8 = Book.create(title: "Bored as Hell", author: author6, publisher: pub3, price: 10.96)
book9 = Book.create(title: "History of the Silk Road", author: author7, publisher: pub3, price: 8.10)
booka = Book.create(title: "Something for Later", author: author1, publisher: author1, price: 9.54)
bookb = Book.create(title: "What If", author: author3, publisher: author3, price: 13.32)
bookc = Book.create(title: "Lilly Reborn", author: author4, publisher: pub3, price: 16.43)
bookd = Book.create(title: "Anathema", author: author5, publisher: author5, price: 9.41)
booke = Book.create(title: "Best Of", author: author2, publisher: pub3, price: 12.24)
bookf = Book.create(title: "Anyway", author: author6, publisher: pub3, price: 19.99)
Run the command to seed the database.
$ bin/rails db:seed
We should be set!
Before we boot we will include Active Model Serializers for JSON:API serialization:
$ echo 'gem "active_model_serializers", "~> 0.10.7"' >> Gemfile
$ bundle
And configure Rails for JSON API serialization in two steps:
Create an initializer at config/initializers/json_api.rb
with the following code:
ActiveSupport.on_load(:action_controller) do
require 'active_model_serializers/register_jsonapi_renderer'
end
ActiveModelSerializers.config.adapter = :json_api
Generate appropriate serializers for each class:
$ bin/rails g serializer book
$ bin/rails g serializer author
$ bin/rails g serializer publishing_house
Run the server and try to fetch data with curl
:
$ bin/rails server
=> Booting Puma
=> Rails 5.1.5 application starting in development
# in another tab/window
$ curl http://localhost:3000/publishing_houses
# if you want pretty printing (and have python or jq installed)
$ curl -s http://localhost:3000/publishing_houses | python -m json.tool
$ curl -s http://localhost:3000/publishing_houses | jq '.'
The server should respond something like:
{
"data": [
{
"id": "1",
"type": "publishing-houses"
},
{
"id": "2",
"type": "publishing-houses"
},
{
"id": "3",
"type": "publishing-houses"
}
]
}
Success? I hope! No other attributes than id
are present for now. We will add them in a minute.
Generate models with Ember CLI:
$ ember generate model book title:string price:number author:belongs-to publisher:belongs-to
$ ember g model author name:string books:has-many
$ ember g model publishing-house name:string discount:number books:has-many
Reviewing our model specs we notice that an author can have many books and that he can publish. Publishing houses also have a bunch of published books.
We must ensure our models and serializers are set up correctly. For your convenience, I highlighted the modified lines.
Snacks is the best of Ember Octane in a highly digestible monthly newsletter. (No spam. EVER.)
Book
modelNothing to change here. Note that publisher
is not a model but a polymorphic interface.
# app/models/book.rb
class Book < ApplicationRecord
belongs_to :author
belongs_to :publisher, polymorphic: true
end
BookSerializer
Include author and publisher id
s (and type!) in the output.
# app/serializers/book_serializer.rb
class BookSerializer < ActiveModel::Serializer
attributes :id, :title, :price
belongs_to :author
belongs_to :publisher
end
Author
modelAuthors that behave as publishers offer a fixed 10% discount. There is no field in the database: we will return a constant.
Here we have to add two has_many :books
statements. First one because an author authors books. And the second one because he can publish books. As both are book collections, we will alter the name of the published items and call it published
. Rails can't infere those relationship details so we need to tell it where the table and foreign key are located.
The published
books are published by a publisher
(our polymorphic interface). We associate it using the as
keyword.
# app/models/author.rb
class Author < ApplicationRecord
def discount() 10 end
has_many :books
has_many :published, foreign_key: :publisher_id, class_name: 'Book', as: :publisher
end
AuthorSerializer
Ask the serializer to include authored and published books’ id
s. Additionally, add the :discount
property.
# app/serializers/author_serializer.rb
class AuthorSerializer < ActiveModel::Serializer
attributes :id, :name, :discount
has_many :books
has_many :published
end
PublishingHouse
modelIn Author
we changed :books
to :published
. Likewise, we need to make that change to PublishingHouse
.
# app/models/publishing_house.rb
class PublishingHouse < ApplicationRecord
has_many :published, as: :publisher, foreign_key: :publisher_id, class_name: 'Book'
end
PublishingHouseSerializer
We simply add the aforementioned published
property in order to get those published book id
s in our JSON.
# app/serializers/publishing_house_serializer.rb
class PublishingHouseSerializer < ActiveModel::Serializer
attributes :id, :name, :discount
has_many :published
end
An abstract Publisher
is necessary to model the polymorphic relationship in Ember.
$ ember g model publisher
Publisher
modelOnce created, we add all common properties to publishers: name
, discount
, published
.
// app/models/publisher.js
import Model from 'ember-data/model';
import attr from 'ember-data/attr';
import { hasMany } from 'ember-data/relationships';
export default Model.extend({
name: attr(),
discount: attr('number'),
published: hasMany('book')
});
published
are asynchronous by default.PublishingHouse
modelNot much here. PublishingHouse
simply extends Publisher
.
// app/models/publishing-house.js
import Publisher from './publisher';
export default Publisher.extend();
publishing-house
.Author
modelAuthor
extends Publisher
, too. The difference with PublishingHouse
, is that an author has a collection of authored books.
// app/models/author.js
import Publisher from './publisher';
import attr from 'ember-data/attr';
import { hasMany } from 'ember-data/relationships';
export default Publisher.extend({
name: attr(),
books: hasMany()
});
Book
modelThe code below explains itself. We have two belongsTo
relationships: one to the author and the other one to a publisher, which is polymorphic.
// app/models/book.js
import Model from 'ember-data/model';
import attr from 'ember-data/attr';
import { belongsTo } from 'ember-data/relationships';
export default Model.extend({
title: attr(),
price: attr('number'),
author: belongsTo('author', { inverse: 'books' }),
publisher: belongsTo('publisher', { polymorphic: true, inverse: 'published' })
});
What is inverse
you may ask? Turns out that Author
has two book collections: books
(authored) and published
. Inverse is how Ember Data deals with these ambiguities.
Well, that was a lot! (But we learned a lot, too.) Let's get ready to explore our data from the client.
If you still don't have Ember Inspector go ahead now and grab it.
Let's boot Ember CLI server. We are going to use the --proxy
flag to let it know our backend is listening at port 3000.
$ ember server --proxy http://localhost:3000
Proxying to http://localhost:3000
Livereload server on http://localhost:49152
Serving on http://localhost:4200/
Navigate to http://localhost:4200 and open Developer Tools. Go to Ember and click on the Data tab. Leave an active console, too.
Get hold of the application route through the Inspector's $E
special variable (here's how). We are particularly interested in accessing its reference to the store
.
$E.store.findAll('book');
Great - now we have all books loaded in our local store.
Time to play around with peekAll
, peekRecord
, and so on. Try promises on relationships. For example:
$E.store.peekAll('book').objectAt(6).get('author').then(data => alert(data.get('name')))
$E.store.peekRecord('author', 2).get('books').map(value => value.get('title'))
Once you're ready, let's carry on by cleaning up the store:
$E.store.unloadAll()
Snacks is the best of Ember Octane in a highly digestible monthly newsletter. (No spam. EVER.)
In order to save a new book, we will have to configure our controllers to deserialize the incoming parameters – turn them from JSON API into a standard Ruby hash. In this example, our BooksController
:
# app/controllers/books_controller.rb
# ...
def book_params
res = ActiveModelSerializers::Deserialization.jsonapi_parse(params, polymorphic: [:publisher])
res[:publisher_type] = res[:publisher_type].singularize.capitalize
res
end
Read this if you are curious about the modification of the :publisher_type
attribute above.
Ember adheres to JSON API and sends payload keys in plural lowercase. For example, authors
.
It turns out the AMS key_transform
is not very smart with deserialization. Rails does not understand that polymorphic type authors
refers to the Author
class. Without that singularization/capitalization line, Rails isn't able to find the class and throws an exception.
So, if and only if we wanted to remove the singularization/capitalization above, we could create a serializer…
…to adjust the request payload keys so that Rails can understand the types:
Author James Jackson just self-published a new book called “Ember and Coal”. Let's store it in our system.
We know James is author with id
1. In order to find him we will use findRecord
which returns a Promise.
var james;
$E.store.findRecord('author', 1).then(author => { james = author });
Alternatively, we could have retrieved James synchronously if we knew he was loaded in the local store. That is what peekRecord
is for.
const james = $E.store.peekRecord('author', 1);
Either way, we can use the james
object to create his new book. Remember that publisher
is polymorphic and will accept a PublishingHouse
or an Author
. In this case James is publishing his own book, so that will be an Author
.
const book = $E.store.createRecord('book', { title: "Ember and Coal", price: 9.99, author: james, publisher: james});
book.save();
Awesome.
Calling james.get('books')
will get us all of his books (2 in total).
Our UX team has asked us to display the (soon to come) author page as fast as possible.
We decided that whenever we load an author, it will bring all its books with it – in the same request. In authors_controller.rb
we'll define:
# app/controllers/authors_controller.rb
# GET /authors/1
def show
render json: @author, include: ['books']
end
Let's reload the browser and then run in the console:
$E.store.findRecord('author', 4)
We should see the requested author Amanda Djidjinski along with her three books.
(Check the Network panel, only one request was fired!)
I created repositories for both the Bookstore client and the Bookstore API on Github.
In our next instalment, we'll use this data skeleton to work on routes and controllers for the Bookstore. We'll learn to deal with metadata, errors, background
fetches, caching via shouldReloadRecord
, query pameters and much more.
Were you able to make your app run fine? Any questions or problems along the way? Let me know in the comments!
Snacks is the best of Ember Octane in a highly digestible monthly newsletter. (No spam. EVER.)