Skip to content

Searching another API ​

A SearchRoot subclass searches an API other than a Search API Decoupled endpoint. The root has three request hooks: one builds the request, one sends it, and one maps the response to the shape the components read. Override the hooks that differ for your API, and register the subclass under a tag of your own, used in place of search-root. A subclass maps one API, so a site with two APIs has two subclasses.

The request hooks ​

HookReceivesReturnsDefault
_buildSearchRequest(criteria)The parsed SearchCriteria.A Request.A request to the url attribute, with the facet parameters in the form Search API Decoupled reads.
_fetchSearchResponse(request, criteria, signal)The request, the criteria, and an AbortSignal.What _mapSearchResponse() receives.Calls fetch, throws a SearchRequestError for a status that is not OK, and decodes the JSON body.
_mapSearchResponse(body, criteria)What _fetchSearchResponse() returned.A SearchResponseType.The body unchanged.

Each hook may return a promise.

criteria holds:

  • keywords: the q parameter, or ''.
  • page: the page, counted from 0.
  • limit: the limit parameter, or the root's defaultPerPage.
  • sort and order: the parameters, each absent when the URL has none. Fill an absent one from your API's defaults.
  • facets: the applied values keyed by facet URL alias, e.g., {brand: ['acme']}. Values from additionalParams are included, so the API receives them. The components do not show those values.
  • params: the merged raw parameters.

this.endpointUrl is the url attribute without its query string. A path that starts with / becomes an absolute URL on the page's origin. A full URL stays as written. A relative path, e.g., api/products, stays relative, and new URL(this.endpointUrl) throws on it.

The mapped response needs every required key. For the list, see The response. A missing key ends the search as failed, with swc-search-error. Set default_sort and default_sort_order when your API sorts by default, so search-sort shows the option in use. Each facet needs settings.url_alias, and settings.widget can be {}.

A worked example ​

This root searches a product catalog API. The API takes:

  • text
  • offset
  • size
  • sort
  • one parameter per facet

It answers with items, total, and aggregations.

CatalogResult is the result element the results are mapped to. For how to write one, see Custom result elements. Its @property() on a class field needs these compiler options:

json
{
  "compilerOptions": {
    "experimentalDecorators": true,
    "useDefineForClassFields": false
  }
}
ts
import {SearchRoot} from 'drupal-search-web-components/dist/search-root/search-root.js';
import {BaseSearchElement} from 'drupal-search-web-components/dist/BaseSearchElement.js';
import type {
  SearchCriteria,
  SearchResponseType,
} from 'drupal-search-web-components/dist/types.js';
import {html} from 'lit';
import {property} from 'lit/decorators.js';

declare const token: string;

interface CatalogResponse {
  items: {sku: string; name: string; href: string; kind: string}[];
  total: number;
  aggregations: {
    field: string;
    label: string;
    buckets: {value: string; count: number}[];
  }[];
}

class CatalogSearchRoot extends SearchRoot {
  override _buildSearchRequest(criteria: SearchCriteria) {
    const url = new URL(this.endpointUrl);
    url.searchParams.set('text', criteria.keywords);
    url.searchParams.set('offset', String(criteria.page * criteria.limit));
    url.searchParams.set('size', String(criteria.limit));
    if (criteria.sort) {
      url.searchParams.set(
        'sort',
        `${criteria.sort}:${criteria.order ?? 'asc'}`
      );
    }
    for (const [alias, values] of Object.entries(criteria.facets)) {
      url.searchParams.set(alias, values.join(','));
    }
    return new Request(url, {headers: {Authorization: `Bearer ${token}`}});
  }

  override _mapSearchResponse(
    body: unknown,
    criteria: SearchCriteria
  ): SearchResponseType {
    const data = body as CatalogResponse;
    return {
      search_results: data.items.map((item) => ({
        id: item.sku,
        title: item.name,
        url: item.href,
        kind: item.kind,
      })),
      search_results_count: data.total,
      search_results_page: criteria.page,
      search_results_pages: Math.ceil(data.total / criteria.limit),
      search_results_per_page: criteria.limit,
      default_sort: 'price',
      default_sort_order: 'asc',
      facets: data.aggregations.map((agg) => ({
        key: agg.field,
        label: agg.label,
        count: agg.buckets.length,
        active_values: criteria.facets[agg.field] ?? [],
        results: agg.buckets.map((b) => ({
          key: b.value,
          label: b.value,
          count: b.count,
          active: (criteria.facets[agg.field] ?? []).includes(b.value),
          children: [],
          in_active_trail: false,
          children_expanded: false,
        })),
        settings: {url_alias: agg.field, show_title: true, widget: {}},
      })),
    };
  }
}
customElements.define('catalog-search-root', CatalogSearchRoot);

class CatalogResult extends BaseSearchElement {
  @property({type: Object})
  data: {title: string; url: string} = {title: '', url: ''};
  override render() {
    return html`<a href=${this.data.url}>${this.data.title}</a>`;
  }
}
customElements.define('catalog-result', CatalogResult);
html
<catalog-search-root url="YOUR_ENDPOINT" defaultPerPage="24">
  <search-input></search-input>
  <search-sort
    sorts='[{"key":"price","order":"asc","label":"Price"},{"key":"name","order":"asc","label":"Name"}]'
  ></search-sort>
  <facet-checkbox key="kind" showLabel showCount></facet-checkbox>
  <search-results
    mappings='[{"keys":["default"],"element":"catalog-result","settings":{}}]'
  ></search-results>
  <search-result-summary></search-result-summary>
  <search-simple-pager pagesToDisplay="2"></search-simple-pager>
</catalog-search-root>

pagesToDisplay="2" shows two page buttons on each side of the current page. Without it, the current page is the only page number button.

The same code runs here against a mock catalog with a page size of 5:

Wrapping the default ​

To keep the Search API Decoupled request and add a header, wrap the default:

ts
class SignedSearchRoot extends SearchRoot {
  override async _buildSearchRequest(criteria: SearchCriteria) {
    const request = await super._buildSearchRequest(criteria);
    return new Request(request, {headers: {Authorization: `Bearer ${token}`}});
  }
}

To read a total from a header or treat a 404 as no results, override _fetchSearchResponse() and return what your map hook expects. Pass the signal on, because it aborts the request when a newer search starts.

ts
override async _fetchSearchResponse(
  request: Request,
  criteria: SearchCriteria,
  signal: AbortSignal
) {
  const response = await fetch(request, {signal});
  if (response.status === 404) {
    return {items: [], total: 0, aggregations: []};
  }
  if (!response.ok) {
    throw new SearchRequestError(response.status, await response.text());
  }
  return {
    ...(await response.json()),
    total: Number(response.headers.get('X-Total-Count')),
  };
}

SearchRequestError comes from drupal-search-web-components/dist/SearchRequestError.js.

Errors and outcomes ​

A request that fails, or a hook that throws:

  • logs the error to the browser console
  • clears the results
  • gives the root the class search-error in place of has-results or no-results
  • dispatches swc-search-error with error, query, searchQuery, criteria, context, and requestedBy on event.detail.

The default fetch hook throws a SearchRequestError with the response's status and body. A map hook can throw to report an error the API sends in a successful response.

When a newer search starts before an older one lands, the older request is aborted. Its swc-post-search has outcome: 'superseded', and no error is reported for it.

On a Drupal site ​

On a Drupal site, the subclass takes SearchRoot from window.SearchWebComponents in place of the import, and goes in a library that depends on search_web_components/components. A layout or template of your own renders <catalog-search-root>, since the Search Web Components layouts render <search-root>. Its url is the API's URL: nothing but your hooks reads it, as this.endpointUrl. For the library and the template, see A subclass on a Drupal page.