EmberData Request Service Cheat Sheet
This guide is a cheat sheet for using EmberData's Request Service. It doesn't cover everything, but it should get you started! PRs welcome at the GitHub repository.
For in-depth information about the upgrade paths and differences compared to older EmberData patterns, see the RFC for the EmberData Request Service.
Fetching Data
§findRecord
§Examples here are shown for apps that use JSON:API. Apps using other paradigms should use the builders for REST or ActiveRecord if applicable, or author their own (or a new community lib!) if not.
Older patterns
const user = await this.store.findRecord('user', '1');
import type User from "my-app/models/user";
const user = await this.store.findRecord<User>('user', '1');
Request Service
import { findRecord } from '@ember-data/json-api/request';
const result = await store.request(findRecord('user', '1'));
const user = result.content.data
import { findRecord } from '@ember-data/json-api/request';
import type { User } from 'my-app/models/user';
const { content } = await store.request(findRecord<User>('user', '1'));
const user = content.data
// Bring your own builder
import { buildBaseURL, buildQueryParams } from '@ember-data/request-utils'
import { pluralize } from 'ember-inflector';
import type { FindRecordUrlOptions } from '@ember-data/request-utils';
import type { RequestSignature } from '@warp-drive/core-types/symbols';
import type { TypeFromInstance } from '@warp-drive/core-types/record';
import type { FindRecordOptions } from '@warp-drive/core-types/request';
type MyRequest<Type> = {
url: string
method: 'GET'
headers: Headers
op: 'findRecord'
records: Array<{ type: TypeFromInstance<Type>, id: string }>;
[RequestSignature]: Type
}
function findRecord<Type>(type: TypeFromInstance<Type>, id: string, options: FindRecordOptions<Type>): MyRequest<Type> {
const identifier = { type, id };
const urlOptions: Partial<FindRecordUrlOptions> = {
op: 'findRecord',
identifier,
resourcePath: pluralize(identifier.type),
};
const url = buildBaseURL(urlOptions);
const headers = new Headers();
headers.append('Accept', 'application/vnd.api+json');
headers.append('Content-Type', 'application/vnd.api+json');
const result = {
url: options.include?.length
? `${url}?${buildQueryParams({ include: options.include }, options.urlParamsSettings)}`
: url,
method: 'GET',
headers,
op: 'findRecord',
records: [identifier],
};
return result as MyRequest<Type>;
}
export default {
findRecord
};
findAll
§
There is no direct replacement for findAll
, you can use query
without extra options instead. Here is how to achieve exact findAll
behavior:
We discourage using peekAll
. When you made a request, you need to guarantee that everything you requested is part of response you get back.
Older patterns
const users = this.store.findAll('user')
Request Service
import { query } from '@ember-data/json-api/request';
await store.request(query('user'));
const users = store.peekAll('user')
query
§
The query
just moved out from being on Store
public interface to builders.
Older patterns
const users = await this.store.query('user', { filter: { name: 'John' } });
Request Service
import { query } from '@ember-data/json-api/request';
const result = await store.request(query('user', { filter: { name: 'John' } }));
const users = result.content.data;
queryRecord
§
There is no direct replacement of queryRecord
. You can just use query
with limit=1
option.
Older patterns
const user = await this.store.queryRecord('user', params);
Request Service
import { query } from '@ember-data/json-api/request';
const result = await store.request(query('user', { ...params, limit: 1 }));
const user = result.content.data[0] ?? null;
Creating/Updating Data
§createRecord
§
To create a new record using Ember Data you should use createRecord
request and attach "body"
to it. In case of JSON:API backend - you can user serializeResources
request utility.
Older patterns
const record = this.store.createRecord('user', { name: "Chris" });
await record.save();
Request Service
import { recordIdentifierFor } from '@ember-data/store';
import { createRecord, serializeResources } from '@ember-data/json-api/request';
const record = store.createRecord('user', {});
const request = createRecord(record);
// You can in place add body to request options
request.body = JSON.stringify(
serializeResources(
store.cache,
recordIdentifierFor(record)
)
);
await store.request(request);
// Create handler for serialization of any record
import { recordIdentifierFor } from '@ember-data/store';
import { serializeResources } from '@ember-data/json-api/request';
const updatesHandler = {
MUTATION_OPS: new Set(['createRecord', 'updateRecord']),
request(context, next) {
if (!MUTATION_OPS.has(context.request.op)) {
// Not a mutation, do nothing
return next(context.request);
}
if (context.request.body) {
// body is already set, do nothing
return next(context.request);
}
const { data, store } = context.request;
const newRequestParams = Object.assign({}, context.request, {
body: serializeResources(
store.cache,
recordIdentifierFor(data.record)
)
});
return next(newRequestParams);
}
}
// Add this handler to your request manager
export default class extends RequestManager {
constructor(args) {
super(args);
this.use([updatesHandler, Fetch]);
}
}
// then in your app just use createRecord builder and let handler care about serialization
import { createRecord } from '@ember-data/json-api/request';
const requestObj = createRecord(record);
await store.request(requestObj);
// or overwrite body if you need to, handler will not touch it
import { createRecord } from '@ember-data/json-api/request';
const record = store.createRecord('feature', { name: "rest-enabled" });
const request = createRecord(record);
// For some reason your endpoint for 'features' is not JSON:API compliant
request.body = JSON.stringify({ name: 'rest-enabled' })
saveRecord / updateRecord
§
To update record, you should send updateRecord
request to your backend and attach "body"
to it. Based on your backend server needs, you can use serializeResources
or serializePatch
request utilities.
Older patterns
const user = this.store.peekRecord('user', '1');
user.name = 'Chris';
await user.save();
Request Service
import { recordIdentifierFor } from '@ember-data/store';
import { updateRecord, serializePatch } from '@ember-data/json-api/request';
user.name = 'Chris';
const request = updateRecord(user);
request.body = JSON.stringify(
serializePatch(
store.cache,
recordIdentifierFor(user)
)
);
await store.request(request);
// Create handler for serialization of any record
import { recordIdentifierFor } from '@ember-data/store';
import { serializeResources } from '@ember-data/json-api/request';
const updatesHandler = {
MUTATION_OPS: new Set(['createRecord', 'updateRecord']),
request(context, next) {
if (!MUTATION_OPS.has(context.request.op)) {
// Not a mutation, do nothing
return next(context.request);
}
if (context.request.body) {
// body is already set, do nothing
return next(context.request);
}
const { data, store } = context.request;
const newRequestParams = Object.assign({}, context.request, {
body: serializeResources(
store.cache,
recordIdentifierFor(data.record)
)
});
return next(newRequestParams);
}
}
// Add this handler to your request manager
export default class extends RequestManager {
constructor(args) {
super(args);
this.use([updatesHandler, Fetch]);
}
}
// then in your app just use updateRecord builder and let handler care about serialization
import { updateRecord } from '@ember-data/json-api/request';
const requestObj = updateRecord(record);
await store.request(requestObj);
// or overwrite body if you need to, handler will not touch it
import { updateRecord } from '@ember-data/rest/request';
const record = store.updateRecord('feature', { name: "rest-enabled" });
const request = updateRecord(record);
// For some reason your endpoint for 'features' is not JSON:API compliant
request.body = JSON.stringify({ name: 'rest-enabled' })
Deleting data
§deleteRecord
§
To delete an existing record using Ember Data you should use deleteRecord
builder to issue the request.
Older patterns
const user = this.store.peekRecord('user', '1');
user.deleteRecord();
await user.save();
user.unloadRecord();
const user = store.peekRecord('user', '1');
await user.destroyRecord();
Request Service
import { deleteRecord } from '@ember-data/json-api/request';
const user = store.peekRecord('user', '1');
store.deleteRecord(user);
await store.request(deleteRecord(user));
store.unloadRecord(user);
Adapters
§Adapters in general
§
In order to provide migration support for Adapters and Serializers, a LegacyNetworkHandler
is provided. This handler takes a request and converts it into the older form, calling the appropriate Adapter and Serializer methods. If no adapter exists for the type (including no application adapter), this handler calls next
. In this manner an app can incrementally migrate request-handling to this new paradigm on a per-type basis as desired.
The package ember-data
automatically configures this handler. If not using the ember-data
package, this configuration needs to be done explicitly.
We intend to support this handler through at least the 5.x series -- not deprecating its usage before 6.0.
Similarly, the methods adapterFor
and serializerFor
will not be deprecated until at least 6.0; however, it should no longer be assumed that an application has an adapter or serializer at all.
Default Host and Namespace
§
To set the default host
and namespace
for requests, you can use the setBuildURLConfig
method of @ember-data/request-utils
package.
This is a module-level function, so you can do it anywhere in your application theoretically, but we recommend doing it in the app/app.js
file
Older patterns
import JSONAPIAdapter from '@ember-data/adapter/json-api';
import config from 'my-app/config/environment';
export default class ApplicationAdapter extends JSONAPIAdapter {
host = config.api.host;
namespace = config.api.namespace;
}
Request Service
import { setBuildURLConfig } from '@ember-data/request-utils';
import config from 'my-app/config/environment';
setBuildURLConfig({
host: config.api.host,
namespace: config.api.namespace,
});
Use request builders instead of pathForType
§
To modify a URL for a request you can use the resourcePath
option for every request builder. Default configuration for JSON:API
, REST
, and ActiveRecord
builders will be provided by EmberData.
We recommend creating your own utility file with request builders that suite your backend's needs.
Older patterns
export default class ApplicationAdapter extends JSONAPIAdapter {
pathForType(type) {
const collectionName = pluralize(camelize(type));
return `collections/${collectionName}/records`;
}
}
Request Service
import { findRecord } from '@ember-data/json-api/request';
// import { findRecord } from '@ember-data/rest/request';
// import { findRecord } from '@ember-data/active-record/request';
const dynamicPathFor = (identifierType) => {
const collectionName = camelize(pluralize(identifierType))
return `collections/${collectionName}/records`;
};
const options = findRecord('post', '1', {
resourcePath: dynamicPathFor('post'),
})
// utils/my-backend-request-builders.js
import { camelize } from '@ember/string';
import { pluralize } from 'ember-inflector';
import { buildBaseURL, buildQueryParams } from '@ember-data/request-utils';
const _customResourcePath = (identifierType) => {
return `collections/${camelize(pluralize(identifierType))}/records`;
};
async function findRecord(typeOrIdentifier, idOrOptions, maybeOptions) {
const identifier = typeof typeOrIdentifier === 'string' ? { type: typeOrIdentifier, id } : typeOrIdentifier;
const options = ((typeof typeOrIdentifier === 'string' ? maybeOptions : idOrOptions) || {});
const urlOptions = {
op: 'findRecord',
identifier,
resourcePath: _customResourcePath(identifier.type),
};
const url = buildBaseURL(urlOptions);
const headers = new Headers();
headers.append('Accept', 'application/vnd.api+json');
headers.append('Content-Type', 'application/vnd.api+json');
return {
url: options.include?.length
? `${url}?${buildQueryParams({ include: options.include }, options.urlParamsSettings)}`
: url,
method: 'GET',
headers,
op: 'findRecord',
records: [identifier],
};
}
export default {
findRecord
};
Cache lifetime
§
In the past, cache lifetimes for single resources were controlled by either supplying the reload
and backgroundReload
options or by the Adapter's hooks for shouldReloadRecord
, shouldReloadAll
, shouldBackgroundReloadRecord
and shouldBackgroundReloadAll
. This behavior will now be controlled by the combination of either supplying cacheOptions
on the associated RequestInfo
or by supplying a lifetimes
service to the Store
.
Older patterns
import JSONAPIAdapter from '@ember-data/adapter/json-api';
export default class ApplicationAdapter extends JSONAPIAdapter {
shouldBackgroundReloadAll(store, snapshotArray) {
let { downlink, effectiveType } = navigator.connection;
return downlink > 0 && effectiveType === '4g';
}
shouldBackgroundReloadRecord(store, snapshot) {
let { downlink, effectiveType } = navigator.connection;
return downlink > 0 && effectiveType === '4g';
}
shouldReloadRecord(store, ticketSnapshot) {
let lastAccessedAt = ticketSnapshot.attr('lastAccessedAt');
let timeDiff = moment().diff(lastAccessedAt, 'minutes');
if (timeDiff > 20) {
return true;
} else {
return false;
}
}
shouldReloadAll(store, snapshotArray) {
let snapshots = snapshotArray.snapshots();
return snapshots.any((ticketSnapshot) => {
let lastAccessedAt = ticketSnapshot.attr('lastAccessedAt');
let timeDiff = moment().diff(lastAccessedAt, 'minutes');
if (timeDiff > 20) {
return true;
} else {
return false;
}
});
}
}
Request Service
import { CachePolicy } from '@ember-data/request-utils';
import BaseStore from 'ember-data/store';
export default class Store extends BaseStore {
constructor(args) {
super(args);
// This is default configuration that would be set automatically be Ember Data
this.lifetimes = new CachePolicy(this, {
apiCacheSoftExpires: 30_000,
apiCacheHardExpires: 60_000
});
// Or you can overwrite it with your own logic
this.lifetimes = {
isHardExpired(identifier) {
const cached = this.store.cache.peekRequest(identifier);
const twentyMinutesInMs = 20 * 60 * 1000;
function isStale(headers, expirationTime) {
const date = headers.get('date');
if (!date) {
return true;
}
const time = new Date(date).getTime();
const now = Date.now();
const deadline = time + expirationTime;
const result = now > deadline;
return result;
}
return !cached || !cached.response || isStale(cached.response.headers, twentyMinutesInMs);
},
isSoftExpired(identifier) {
const { downlink, effectiveType } = navigator.connection;
return downlink > 0 && effectiveType === '4g';
}
}
}
}
Serializers
§Serializers in general
§
In order to provide migration support for Adapters and Serializers, a LegacyNetworkHandler
is provided. This handler takes a request and converts it into the older form, calling the appropriate Adapter and Serializer methods. If no adapter exists for the type (including no application adapter), this handler calls next
. In this manner an app can incrementally migrate request-handling to this new paradigm on a per-type basis as desired.
The package ember-data
automatically configures this handler. If not using the ember-data
package, this configuration needs to be done explicitly.
We intend to support this handler through at least the 5.x series -- not deprecating its usage before 6.0.
Similarly, the methods adapterFor
and serializerFor
will not be deprecated until at least 6.0; however, it should no longer be assumed that an application has an adapter or serializer at all.
Serializers previously was used to accomplish two main things: serialization and normalization.
Normalization is now done in handlers, before returning the response.
Serialization can be done in handler, but we encourage application developers to do it in request builder.
Older patterns
import JSONAPISerializer from '@ember-data/serializer/json-api';
export default class ApplicationSerializer extends JSONAPISerializer {
primaryKey = '_id';
keyForAttribute(attr) {
return attr.replace(/_/g, '-'); // blog_post_comment becomes blog-post-comment
}
keyForRelationship(key, relationship) {
return key + 'Ids';
}
serialize(snapshot, options) {
let json = super.serialize(...arguments);
json.data.attributes.cost = {
amount: json.data.attributes.amount,
currency: json.data.attributes.currency,
};
delete json.data.attributes.amount;
delete json.data.attributes.currency;
return json;
}
normalizeResponse(store, primaryModelClass, payload, id, requestType) {
payload.data.attributes.amount = payload.data.attributes.cost.amount;
payload.data.attributes.currency = payload.data.attributes.cost.currency;
delete payload.data.attributes.cost;
return super.normalizeResponse(...arguments);
}
}
Request Service
// File: my-app/utils/serialization.js
const keyForAttribute = (attr) => {
return attr.replace(/_/g, '-'); // blog_post_comment becomes blog-post-comment
}
const keyForRelationship = (relationship) => {
return relationship + 'Ids';
}
const serializeResource = (cache, identifier) => {
const body = { _id: identifier.id, type: identifier.type };
if (cache.hasChangedAttrs(identifier)) {
const attrsChanges = cache.changedAttrs(identifier);
Object.keys(attrsChanges).forEach((attr) => {
const change = attrsChanges[attr][1];
body[keyForAttribute(attr)] = change === undefined ? null : change;
});
}
if (cache.hasChangedRelationships(identifier)) {
const relationshipsChanged = cache.changedRelationships(identifier);
if (relationshipsChanged.size) {
relationshipsChanged.forEach((diff, key) => {
body[keyForRelationship(key)] = diff.localState.id;
});
}
}
return body;
};
const normalizeResource = (item, schema) => {
// destructuring and renaming primaryKey from _id to id
const { _id: id, type } = item;
const attributesDefined = schema.attributesDefinitionFor({ type });
const relationshipsDefined = schema.relationshipsDefinitionFor({ type });
const data = { id, type, attributes: {} };
for (const attr of Object.values(attributesDefined)) {
data.attributes[attr.name] = item[attr.name];
}
data.attributes.amount = item.data.attributes.cost.amount;
data.attributes.currency = item.data.attributes.cost.currency;
for (const rel of Object.values(relationshipsDefined)) {
if (Object.hasOwnProperty.call(item, rel.name)) {
data.relationships ??= {};
data.relationships[rel.name] = {
data: {
id: item[rel.name],
type: rel.type,
},
};
}
}
return data;
};
export {
normalizeResource,
serializeResource,
}
// File: my-app/components/my-component.js
import { serializeResource } from "my-app/utils/serialization";
import { recordIdentifierFor } from '@ember-data/store';
import { createRecord } from "@ember-data/json-api/request"
// ...
let record = store.createRecord('blog-post', {
title: "My first blog post",
body: "This is the body of my blog post",
createdAt: new Date(),
user: currentUser,
});
const request = createRecord(record);
request.body = JSON.stringify(
serializeResource(
store.cache,
recordIdentifierFor(record)
)
);
await this.store.request(request);
// File: my-app/utils/my-custom-rest-handler.js
import { normalizeResource } from 'my-app/utils/serialization';
const MyCustomRESTHandler = {
async request(context, next) {
const { content, request } = await next(context.request);
// convert to JSON:API, because EmberData expect it by default (using the JSON:API Cache)
return normalizeResource(content, request.store.schema);
},
};
export default MyCustomRESTHandler;