Skip to content

Builders ​

A builder is a function that returns a request object, the object you hand to store.request. It does not send the request or talk to the network; it describes one: the url, method, headers and body, how the response should be cached, and what type the response has.

A builder doesn't need to be complex. Often it only returns an object with a url:

builders/get-current-user.ts
ts
export function getCurrentUser() {
  return { url: '/api/users/me' };
}

Most builders add a little more: a doc comment describing the endpoint, and the type of its response.

builders/get-current-user.ts
ts
import { withReactiveResponse } from '@warp-drive/core/request';
import type { User } from '#/data/types';

/**
 * Gets the user who is signed in to this session.
 *
 * - Endpoint: `GET /api/users/me`
 */
export function getCurrentUser() {
  return withReactiveResponse<User>({ url: '/api/users/me' });
}
ts
import { getCurrentUser } from '#/builders/get-current-user.ts';

const { content } = await store.request(getCurrentUser());

An SDK For Your API ​

Taken together, an app's builders are its SDK: the one place that says how the app talks to each of its endpoints and why. The rest of the app asks for data by calling a builder, and never needs to know how the request behind it is put together.

A builder captures intent. getCompanyPreviewList(search) says what the caller wants. The builder knows that the endpoint is a QUERY sent as a POST, which fields and related resources a preview needs, how the results are paged and sorted, and which cached requests go stale when a company is created. That knowledge is business logic, and a builder keeps it in one place instead of spreading it across every component that shows a company.

A request object written inline where it is used can't do any of this. A named function can:

  • It documents its contract. A doc comment on the builder can say what the endpoint does, what each argument means, what comes back, how it is paged, and what it invalidates. Editors show that comment wherever the builder is used.
  • It can be found by name. Type get in an editor and autocomplete lists the requests the app knows how to make. A name like getCompanyPreviewList tells a reader what the request is for before they open it.
  • It is reusable. The same builder serves every component that needs the data, both the JS API and the Component API, and your test suite.
  • It works in any framework. A builder is a plain function with no framework imports, so the same SDK works in Ember, React, Vue, Svelte, or plain JavaScript, and can be shared by apps built on different frameworks.

Give every request a builder, even one that is only issued once. It costs one function, and it makes the request easy to find, test, review, and change later.

Writing Builders ​

A set of builders is only useful as an SDK if readers can find a builder and trust what it says.

  • Document every builder. Describe what the request is for and how the endpoint behaves: its arguments, what it returns, paging, sorting, permissions, and which cached requests it invalidates.
  • Give it a name that says what it does, such as getCompanyPreviewList or createContentLike. Name builders after either your endpoints or your business logic, and pick one convention for the whole SDK.
  • Keep builders in one place, such as a builders/ directory or their own package, so the whole SDK can be browsed, reused across the app, and shared with other apps.
  • Keep builders pure. The same arguments should always produce the same request.
  • Set the response type on the request object a builder returns.
  • Rarely rely on Handlers to add information the request can't work without. Handlers are for concerns that apply to many requests, such as authentication.

Here's a builder that follows those rules.

Get A List Of Partial Data ​

Say a feature shows a searchable, paginated, sorted list of companies. The request loads only the few company fields the list shows, plus a small subset of the related data for each company's CEO and headquarters. The endpoint is a QUERY sent using the http POST method, and you want its results cached so that repeated searches are deduplicated.

builders/get-company-preview-list.ts
ts
import { withReactiveResponse } from '@warp-drive/core/request';
import type { CompanyPreview } from '#/data/types';

/**
 * Searches companies by name and returns the first page of
 * {@link CompanyPreview}s, each with its CEO and headquarters.
 *
 * - Endpoint: `QUERY /companies`, sent as a `POST` with the
 *   `X-HTTP-METHOD-OVERRIDE` header.
 * - Sorted alphabetically by name, ascending.
 * - Paginated, 25 companies per page; this builder requests the first
 *   page, and later pages are loaded by following the response's links.
 * - Loads only the fields a preview renders.
 * - Cached by search text, and invalidated whenever a company is created.
 *
 * @param search text matched against the company name
 */
export function getCompanyPreviewList(search: string) {
  const url = `/companies`;
  const body = JSON.stringify({
    search,
    include: ['ceo', 'headquarters'],
    fields: {
      company: ['name', 'ceo', 'headquarters'],
      user: ['name', 'title'],
      address: ['city', 'state']
    },
    page: {
      offset: 0,
      limit: 25,
    },
    sort: ['name:asc']
  });
  // the body's keys are always written in the same order, so this key is stable
  const cacheKey = `${url}::${body}`;

  return withReactiveResponse<CompanyPreview[]>({
    url,
    method: 'POST',
    cacheOptions: {
      key: cacheKey,
      // invalidate this query if new companies are created
      types: ['company']
    },
    headers: {
      'X-HTTP-METHOD-OVERRIDE': 'QUERY'
    },
    body
  });
}

Typed Requests Without Casting ​

A builder is the only way to give a request a response type without a cast. Call withResponseType or withReactiveResponse inside the builder, and every caller gets the type through inference. The type is declared once, next to the endpoint it describes, as part of the builder's contract.

Adding the type anywhere else is a cast. That includes calling withResponseType inline where the request is made, or writing as on the result: the caller asserts a type for a request it didn't define, and nothing keeps that assertion in step with the endpoint. Templating syntaxes that don't accept TypeScript generics can't express a cast at all, while a builder's type reaches <Request /> the same way it reaches store.request.

Typing Requests covers the response, meta, and error types a builder can declare.

Cache Keys for Requests ​

In order for two requests to be considered the same, their RequestKey, also called the CacheKey, must match. For GET requests the RequestKey is typically the url, while queries issued using a POST request (or other means) may need to explicitly set cacheOptions.key.

For the url case, this means that the order and formatting of URLSearchParams must be the same for a match to occur. Similarly, when cacheOptions.key is used sorting and order of the information being used to produce the string key must be considered.

A naive approach to stringifying params or request bodies to use as keys will result in otherwise identical requests failing to match due to mismatched strings. See below for an example of what we mean.

ts
"https://example.com/api/users?ids=1,2"
"https://example.com/api/users?ids=2,1"

Unlike most other aspects of a request which can be adjusted by a handler if needed, the CacheKey must be provided at the point of request and cannot be updated or set later. This means providing a CacheKey (or electing not to provide one) is one of a builder's biggest responsibilities.

💡 TIP

Read the caching section of the manual to understand how this CacheKey is used.

Simply using a builder does a significant amount towards ensuring a stable RequestKey, because the order in which the url is built and params are assigned will be the same each time. But sometimes that isn't enough, and when it is not the following utilities will come in handy:

💡 TIP

All three sort and filter copies. The params you hand them are never mutated, so it is safe to pass state you also render — a tracked array of filter values, say — without the act of building a request reordering what the user sees.

Request The Same Data Anywhere ​

A stable RequestKey means you don't have to load data in one component and pass it down to the others. Any component that needs the current user can call getCurrentUser() itself. While a request is in flight, other requests with the same RequestKey wait on it instead of starting their own, so the network sees one request however many components make it. Once the response is cached, later calls are served from the cache until the CachePolicy considers it stale.

Here, the page header and a settings panel several components below it each ask for the current user, and neither receives it as an argument.

glimmer-ts
import { Request } from '@warp-drive/ember';
import { getCurrentUser } from '#/builders/get-current-user.ts';

// app/components/app-header.gts
export const AppHeader = <template>
  <Request @query={{(getCurrentUser)}}>
    <:content as |result|>Signed in as {{result.data.name}}</:content>
  </Request>
</template>;

// app/components/settings/email-preferences.gts
export const EmailPreferences = <template>
  <Request @query={{(getCurrentUser)}}>
    <:content as |result|>Emails are sent to {{result.data.email}}</:content>
  </Request>
</template>;
tsx
import { Request } from '@warp-drive/react';
import { getCurrentUser } from '#/builders/get-current-user.ts';

// app/components/app-header.tsx
export function AppHeader() {
  return <Request
    query={getCurrentUser()}
    states={{
      content: ({ result }) => <>Signed in as {result.data.name}</>,
    }}
  />;
}

// app/components/settings/email-preferences.tsx
export function EmailPreferences() {
  return <Request
    query={getCurrentUser()}
    states={{
      content: ({ result }) => <>Emails are sent to {result.data.email}</>,
    }}
  />;
}
ts
import { getCurrentUser } from '#/builders/get-current-user.ts';
import { store } from '#/data/store.ts';

// Two unrelated parts of the app request the same data at the same time.
const [header, settings] = await Promise.all([
  store.request(getCurrentUser()),
  store.request(getCurrentUser()),
]);

// The network saw one request, and both results hold the same user.
header.content.data === settings.content.data; // true
Coming Soon!
Coming Soon!

This only works when the RequestKey is the same every time, which is why producing a stable one is a builder's job. A request with no RequestKey, such as a POST that doesn't set cacheOptions.key, is neither deduplicated nor cached.

A builder for a paginated collection requests the first page and nothing more. It doesn't take a page, offset or cursor argument. The response to that first request should carry links to the pages around it, and every later page is loaded by following one of them.

ts
import { searchUsers } from '#/builders/search-users.ts';
import { store } from '#/data/store.ts';

// a GET builder whose endpoint returns links with each page
const { content: firstPage } = await store.request(searchUsers('ada'));

// follows firstPage.links.next, resolving to null when there is no next page
const secondPage = await firstPage.next();

The response's ReactiveDocument exposes links and meta, and its next(), prev(), first() and last() methods request the matching link.

Paginating this way keeps pagination out of the builder's contract:

  • The server owns the paging scheme. Whether the API pages by number, offset, cursor or keyset, the builder's signature stays the same, and a change to that scheme doesn't ripple through every place the app calls the builder.
  • Every page is keyed by its link. A link is a GET URL, so each page gets a stable RequestKey without the builder computing one, and two components following the same link share one request.
  • The app never rebuilds a request. Code that loads the next page doesn't need to remember the search text, filters, fields and sort that the first page was requested with. They are already in the link.
  • Pagination utilities work unchanged. The experimental Pagination primitives, such as getPaginationState, and the Ember <Paginate /> component turn a first-page request into a numbered pager or an infinite list by following these links. Most apps with paginated lists will want them.

If your API doesn't return links for a GET collection yet, a handler can add them to each response before it reaches the cache, because everything it needs is in the request's URL.

Paginating A POST Or QUERY Request ​

Requests like getCompanyPreviewList above, which send their query in the body of a POST (or an http QUERY), are harder. A link is a URL, and a URL has no body, so the server has nothing it can put in a next link that means "the same query, 25 results further on".

The recommended fix is on the server: have it return links anyway, by making the query addressable by URL. There are two common ways to do that.

  • Persist the query. When the server receives the first POST, it stores the query body under an id, such as a hash of the normalized body, and returns links that name that id instead of repeating the body:

    json
    {
      "data": [],
      "links": {
        "self": "/companies/queries/7f3a9c?page[offset]=0&page[limit]=25",
        "next": "/companies/queries/7f3a9c?page[offset]=25&page[limit]=25"
      }
    }

    Following next is a plain GET. The server looks up the stored query by its id and applies the page parameters from the URL. Hashing the body means the same query always maps to the same id, so the server stores it once however many times it is run; include the user in the hash, or check access on every page, when results depend on who is asking. A stored query only needs to outlive how long a client might keep paging through the results. Once it expires, a request for one of its links should fail with an error the app can handle by requesting the first page again.

  • Encode the continuation into the link. A server that can't store queries can serialize what it needs to resume, such as the query and the position of the last result returned, into an opaque, signed cursor, and return a link like /companies/search?cursor=eyJxIjp7.... This keeps the server stateless, at the cost of longer URLs.

Either way, the builder stays exactly as written above, and the app pages through the results with next() like any other collection.

When the server can't do either, a handler can generate links on the client instead: it remembers each POST it sends, gives the response a next link naming it, and turns a request for that link back into a POST for the following page. Paginating POST Queries With A Handler walks through one. Treat it as a fallback, not a first choice: links generated on the client exist only in the app's memory, so they can't be bookmarked, shared or restored after a reload the way server links can.

Passing Builders To Components ​

Because a builder returns a plain object, its result can be passed anywhere a request is expected.

The most common place is the @query argument of the <Request /> component (Ember, React), as the examples above show. Reactive Control Flow covers the states it renders.

The same pattern works for your own components. A select, tree, or table can take a request as an argument and use it to load its options, children, or rows. The component knows how to render the data without knowing which endpoint it comes from, and the parent decides what to load by choosing the builder.

builders/search-users.ts
ts
import { withReactiveResponse } from '@warp-drive/core/request';
import { buildQueryParams } from '@warp-drive/utilities';
import type { User } from '#/data/types';

/**
 * Searches active users by name or email, for pickers such as `UserSelect`.
 *
 * - Endpoint: `GET /api/users`
 * - Returns at most 20 {@link User}s, sorted by name, ascending.
 * - Deactivated users are never returned.
 *
 * @param term text matched against the user's name and email
 */
export function searchUsers(term: string) {
  const params = buildQueryParams({
    search: term,
    'filter[active]': true,
    sort: 'name',
    'page[limit]': 20,
  });

  return withReactiveResponse<User[]>({ url: `/api/users?${params}` });
}

The component's argument is typed with the response it expects, so TypeScript rejects a request that returns something else, and the options are typed without a cast.

Built-in Builders ​

WarpDrive ships general-purpose builders for migrating to the request pipeline and for getting started quickly. They are a starting point, not the goal: a general-purpose builder can't document, type, or encode your app's endpoints and business logic the way your own builders can.

In a mature app, keep general-purpose builders like these as internal infrastructure for building more specific builders, rather than calling them from the rest of the app:

builders/get-user.ts
ts
import { findRecord } from '@warp-drive/utilities/json-api';
import type { User } from '#/data/types';

/**
 * Gets a user by id, with their team.
 *
 * - Endpoint: `GET /api/users/:id?include=team`
 */
export function getUser(id: string) {
  return findRecord<User>('user', id, { include: ['team'] });
}

The rest of the app calls getUser(id), which says what it wants and carries its own documentation, instead of calling findRecord('user', id) directly.

Released under the MIT License.