EmberData Request Service Cheat Sheet

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;