Data
Nothing
{{outlet}}
We're good to go.
Oh just before that, would be nice to improve the styles just a little. Add this to the ''app/styles/app.css'':
body {
padding: 10px;
font-family: sans-serif;
}
ul {
border: 1px solid #BBBBBB;
width: 260px;
border-radius: 4px;
padding: 7px 7px 7px 40px;
}
span {
display: inline-block;
background-color: black;
border-radius: 25px;
color: white;
width: 28px;
height: 28px;
line-height: 28px;
text-align: center;
}
===== Using Inline JSON =====
We'll start by returning some hard-coded JSON from a route for the default template to display. We already have an ''application.hbs'' template, so we will make an ''application.js'' route in which to load our data with:
ember g route application
Say no to overwriting the ''app\templates\application.hbs'' file.
Add a new ''model'' hook to create and return some JSON that will be used to display information about a game:
import Route from '@ember/routing/route';
export default class ApplicationRoute extends Route {
model (){
const game = {
"id": 1,
"title": "The Secret Of Monkey Island",
"year": "1990"
}
return game;
}
}
Now to use those data values in our default ''application.hbs'' template. Replace the content with:
Data
- {{@model.title}} {{@model.id}}
- {{@model.year}}
Result:
{{ :ember:ember-data-template-output01.png?nolink |}}
Great! First result with some data showing on screen. On the downside, we are not using Ember Data just yet!
It is also good to see failure. Let's comment out the return in our model hook:
//return game;
{{ :ember:ember-data-template-output02.png?nolink |}}
Notice we get nothing rendered. We need to return the data object back otherwise the model hook considers it has received nothing. Best to uncomment that line again before moving.
===== Fetching Dummy JSON from a file =====
To start feeling more like a regular application we should ''Fetch'' our data rather than hard-code it within a route.
Create a JSON file in the ''public'' folder called ''game.json''.
Take the data from the model hook and copy it into ''game.json'' as:
{
"id": 1,
"title": "The Secret Of Monkey Island",
"year": "1990"
}
Change your application model hook to load the data from the file:
import Route from '@ember/routing/route';
export default class ApplicationRoute extends Route {
model (){
let promise = fetch('game.json');
return promise.then(function(response){
return response.json();
});
}
}
Alternatively, you could use async/await syntax like this:
import Route from '@ember/routing/route';
export default class ApplicationRoute extends Route {
async model (){
let response = await fetch('game.json');
let data = await response.json();
return data;
}
}
Slightly easier on the eyes.
But either way, the data should be loading into the page from the file using fetch. Nice one.
For the sake of showing that this step can be difficult to get your resolved Promises right, here is some code that does not work for returning the data from a model hook:
model (){
let promise = fetch('game.json');
promise.then(function(response){
return response.json()
.then(function(game){
return game;
});
});
return promise;
}
===== Fetching from a remote API =====
What about a similar API that is remote? Let's see how we go with that. At this point we need be online. I'll provide a RESTful test API at https://retrogames.waynejohnson.net/api
You can call the entire list of games with: https://retrogames.waynejohnson.net/api/games
Or you can call a single game with: https://retrogames.waynejohnson.net/api/games/{id}
First port of call, lets swap out the fetch call so that it makes a call to the API to get one record out of many:
import Route from '@ember/routing/route';
export default class ApplicationRoute extends Route {
model (){
let promise = fetch('https://retrogames.waynejohnson.net/api/games/7');
return promise.then(function(response){
return response.json();
});
}
}
And that works too. But notice there is a subtle problem that will catch us up later. It's the id.
{{ :ember:ember-data-template-output03.png?nolink |}}
{
"gameId": 7,
"title": "Beneath a Steel Sky",
"year": 1994
}
Notice that the ID is no longer displaying on our page. This is because we are expecting a property called ''id'' but our API is returning it as ''gameId''.
We could work around the problem by altering the JSON with:
model (){
let promise = fetch('https://retrogames.waynejohnson.net/api/games/7');
return promise.then(function(response){
return response.json().then(function(json){
json.id = json.gameId;
return json;
});
});
}
{{ :ember:ember-data-template-output04.png?nolink |}}
...but... as we get into collections (more than just a single record) and more complex results and patching, this is going to get very messy quickly. It's probably time to utilise Ember Data and have it do all the heavy lifting for us.
===== Fetching from an API using Ember Data =====
Now for the meatier stuff: actually using Ember Data to call the API for us and to bring in the data. For this, we'll start to use models and calls from our routes.
Begin by defining the model, which is the cornerstone of helping Ember Data help us. Create a model file with:
ember g model game
This will create the ''game.js'' model file in the ''app/models'' folder. Change the content to be:
import Model, { attr } from '@ember-data/model';
export default class GameModel extends Model {
@attr('string') title;
@attr('number') year;
}
You might observe that we don't have an ''id'' property. You should not specify an id in your Ember data models. More on this later.
Now because Ember Data prefers to consume JSON:API, we are going to need a custom Adapter to handle where the data is sourced from, and a custom Serializer to handle any massaging that we might need to fix up the JSON data we load in.
Make a new Adapter for our ''game'' model with:
ember g adapter game
By default your new adapter is going to be a ''JSONAPIAdapter'' but that's obviously no good for our customised purposes. So let's change it into a ''RESTAdapter'' and add the ''host'' and ''namespace'' properties to point to our own API:
import RESTAdapter from '@ember-data/adapter/rest';
export default class GameAdapter extends RESTAdapter {
host = 'https://retrogames.waynejohnson.net';
namespace = "api"
}
Let's now make our first attempt to load in data from the model hook in the ''app/routes/application.js'' route file. We need to bring in the ''store service'' by adding it to our includes, and also declare it inside the Route class:
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
export default class ApplicationRoute extends Route {
@service store;
...
Replace the ''model()'' function with:
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
export default class ApplicationRoute extends Route {
@service store;
model (){
let data = this.store.findRecord('game', 3);
return data;
}
}
There's some good news and bad news. If you check the Network tab, you'll notice there was a successful call to our API to retrieve back a record:
200 GET 3 json https://retrogames.waynejohnson.net/api/games/3
{
"gameId": 3,
"title": "Twinsen's Odyssey",
"year": 1997
}
Fantastic. That means that the adapter is doing it's job. If you want to know how the adapter works it's magic: it takes the ''host'' and ''namespace'' properties from the adapter settings, and the ''game'' from our ''this.store.findRecord("game", 3);'', and //pluralises// ''game'' into ''games''. Therefore:
https://retrogames.waynejohnson.net + api + game + plural + index = https://retrogames.waynejohnson.net/api/games/3
Now the bad news... the page crashed with a blank page, and you will get an error in the console:
Error while processing route: index payload.data is null
This is an indicator that we don't have a serializer for Ember Data that understands anything about our JSON structure. So we need to make a Serializer.
===== The Serializer and customising our data =====
We are going to spend some time here because this is the part that can be a complete mongrel to get right. Let's get started by making a serializer file with:
ember g serializer game
Now with an empty serializer, we will get the following error because the serializer we created defaults to a ''JSONAPISerializer'':
Error while processing route: index Assertion Failed: normalizeResponse must return a valid JSON API document
Let's turn it into a ''RESTAdapter'':
import RESTSerializer from '@ember-data/serializer/rest';
export default class GameSerializer extends RESTSerializer {
}
Hmmm.. things appear to be worse:
WARNING: Encountered "gameId" in payload, but no model was found for model name "game-id"
WARNING: Encountered "title" in payload, but no model was found for model name "title"
WARNING: Encountered "year" in payload, but no model was found for model name "year"
Error while processing route: index payload.data is null
Or
Error: Assertion Failed: The 'findRecord' request for game:3 resolved indicating success but contained no primary data. To indicate a 404 not found you should either reject the promise returned by the adapter's findRecord method or throw a NotFoundError.
So far, Ember Data doesn't know how to convert our incoming data calls into something that suits our model.
A ''normalizeFindRecordResponse'' function is required in our serializer. Let's add a plain one that does little more than send our payload to ''super'' (super is in charge of converting our data into JSON:API behind the scenes internally):
import RESTSerializer from '@ember-data/serializer/rest';
export default class GameSerializer extends RESTSerializer {
normalizeFindRecordResponse(store, type, payload, id) {
return super.normalizeFindRecordResponse(store, type, payload, id);
}
}
Same error. Seems like we haven't progressed. Why don't we seem to getting anywhere?
Let's take another look at the JSON that is coming back over the wire:
{
"gameId": 3,
"title": "Twinsen's Odyssey",
"year": 1997
}
Notice that the data has no root node. It's just an object. The serializer doesn't know what to make of it or which model it needs to map to. It's true we did tell the ''GameAdapter'' that we wanted a ''game'' object, but unless the server can show that it has returned one or more ''game'' data items, Ember will be upset. The server may have returned an error message, or worse, junk response from a crashed service. We wouldn't want Ember Data to proceed modelling on that "data".
The simple fix is to wrap the incoming data item with a ''game'' node. Let's update the normalizer function to be:
import RESTSerializer from '@ember-data/serializer/rest';
export default class GameSerializer extends RESTSerializer {
normalizeFindRecordResponse(store, type, payload, id) {
const alteredPayload = {
game: { payload }
}
return super.normalizeFindRecordResponse(store, type, alteredPayload, id);
}
}
Hey we've progressed! A different error this time:
Error while processing route: index Assertion Failed: You must include an 'id' for game in an object passed to 'push'
And you might be able to guess the reason for this? Remember how our JSON that was returned from the API had a ''gameId'', but our template ''application.hbs'' is expecting it to be ''id''? So we need to map the ID property. We can massage this within the ''normalizeFindRecordResponse'' function:
import RESTSerializer from '@ember-data/serializer/rest';
export default class GameSerializer extends RESTSerializer {
normalizeFindRecordResponse(store, type, payload, id) {
const alteredPayload = {
game: {
id: payload.gameId,
title: payload.title,
year: payload.year
}
}
return super.normalizeFindRecordResponse(store, type, alteredPayload, id);
}
}
Woah! Hey! We got rendered output onto our page! Fantastic stuff. This means we have a complete working flow for our application for single records.
{{ :ember:ember-data-template-output05.png?nolink |}}
===== Collections of Data =====
Now that we have been successful with a single data record, and we understand that pluralisation and JSON root nodes can make all the difference... let's move on to working with multiple data records.
We'll need to change both our template page, and the route to expect multiple records. Let's start with the ''routes/application.js'':
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
export default class ApplicationRoute extends Route {
@service store;
model (){
let data = this.store.findAll('game');
return data;
}
}
Oooh, a ''findAll'' call. You'll notice we don't have an error, but even still: our data doesn't populate on the page. However... the Network tab will show that the call successully loaded all our records because the GameAdapter constructed a URL like this: ''https://retrogames.waynejohnson.net/api/games''.
How? Again, the adapter takes the ''host'' and ''namespace'' properties, and the ''game'' from our ''this.store.findAll('game');'', and //pluralises// ''game'' into ''games''. Therefore:
https://retrogames.waynejohnson.net + api + game + plural = https://retrogames.waynejohnson.net/api/games
The trick to all this Ember Data stuff is to be aware of what convention needs to be a ''single'' and what needs to be a ''plural''. After that, everything is smooth sailing.
There are a bunch of warnings in the developer console like this:
WARNING: Encountered "0" in payload, but no model was found for model name "0".
This means Ember Data is trying to parse each record, but the RESTSerializer doesn't know what to do with a collection of ''game'' objects coming in. We better teach it by supplying a new ''normalizeFindAllResponse'' function into the RESTSerializer:
normalizeFindAllResponse(store, type, payload) {
return super.normalizeFindAllResponse(store, type, payload);
}
The same warnings occur because we did nothing to massage in the incoming rows. Let's try wrapping the collection of JSON objects with a root node:
normalizeFindAllResponse(store, type, payload) {
const newPayload = {
games: payload
}
return super.normalizeFindAllResponse(store, type, newPayload);
}
Notice the plural ''games'' node for a collection? This is good. The warnings are gone, however and we're back to an error:
Error while processing route: index Assertion Failed: You must include an 'id' for game in an object passed to 'push'
Looks like we need to ensure we massage the ids for each row coming in. Change the RESTSerializer to do this with:
normalizeFindAllResponse(store, type, payload) {
const newPayload = payload.map( g => {
return {
id: g.gameId,
title: g.title,
year: g.year
}
} );
const wrappedPayload = {
games: newPayload
}
return super.normalizeFindAllResponse(store, type, wrappedPayload);
}
That's fixed the data but we can't display it just yet.
That code was a little long too, and could be optimised. But you can easily follow what's happening here. There is an easier way to do this for collections: you can add a ''primaryKey'' property to the RESTSerializer:
import RESTSerializer from '@ember-data/serializer/rest';
export default class GameSerializer extends RESTSerializer {
primaryKey = 'gameId'; //<------ note!!
normalizeFindRecordResponse(store, type, payload, id) {
const alteredPayload = {
game: {
gameId: payload.gameId, // <- Set prop to gameId again. primaryKey will sort this out for us!
title: payload.title,
year: payload.year
}
}
return super.normalizeFindRecordResponse(store, type, alteredPayload, id);
}
normalizeFindAllResponse(store, type, payload) {
const wrappedPayload = {
games: payload
}
return super.normalizeFindAllResponse(store, type, wrappedPayload);
}
}
The errors and warnings are gone, and so therefore all that is left is to update our ''application.hbs'' template to ensure each record is rendered:
Data
{{#each @model as |game|}}
- {{game.title}} {{game.id}}
- {{game.year}}
{{/each}}
And viola! We have a collection of games rendering in a list. And I think we'll stop there, that's plenty enough. But there's much more to learn, such as relationships, also POSTs, PUTs and PATCHes weren't covered. So have fun experimenting!
{{ :ember:ember-data-template-output06.png?nolink |}}
===== Thanks =====
Thank you to Latha.K & Andrew.W for proofing and tips, and Julien.P for wallowing with me with every possible permutation of Promises.
===== Further reading, tips, thoughts =====
- [[https://web.archive.org/web/20230523094239/https://emberigniter.com/fit-any-backend-into-ember-custom-adapters-serializers/ | https://emberigniter.com/fit-any-backend-into-ember-custom-adapters-serializers]]
- https://javascript.info/promise-chaining
- https://guides.emberjs.com/release/routing/specifying-a-routes-model
- https://emberigniter.com/render-promise-before-it-resolves