Developing an action extension

An action extension is a custom endpoint that can be called from your frontend. It allows you to forward any kind of API calls to backend services, including writing data to it. Especially the latter can't be achieved by a data source extension. The other big differences to a data source extension are:

  1. An action needs to be triggered manually from the frontend
  2. Actions can't be configured by Studio users
  3. The data returned by an action isn't automatically available (especially at Server Side Rendering time)

1. Implement the action

Actions are categorized into namespaces for clarity. Namespaces are created by nesting objects in the index.ts:
backend/index.tstypescript
export default {
  actions: {
    'star-wars': {
      character: async (request: Request, actionContext: ActionContext): Promise<Response> => {
        if (!request.query.search) {
          return {
            body: 'Missing search query',
            statusCode: 400,
          };
        }
        return await axios.post('https://frontastic-swapi-graphql.netlify.app/', {
            query: `{
            allPeople(name: "${request.query.search}") {
              totalCount
              pageInfo {
                hasNextPage
                endCursor
              }
              people {
                id
                name
                height
                eyeColor
                species {
                  name
                }
              }
            }
          }`,
          })
          .then((response) => {
            return {
              body: JSON.stringify(response.data),
              statusCode: 200,
            };
          })
          .catch((reason) => {
            return {
              body: reason.body,
              statusCode: 500,
            };
          });
      },
  },
}
This action resides in the star-wars namespace and is named character.

An action receives 2 parameters:

  • The Request is a data object that contains selected attributes of the original HTTP (such as query holding the URL query parameters) and the session object
  • The ActionContext holds contextual information from the API hub
As the return value, a Response or a promise returning such, is expected. This response will be passed as the return value to the client. It contains many of the typical response attributes of a standard HTTP response.
We're using the Axios library to perform HTTP requests here. To reproduce this example, you need to add this as a dependency, for example, using yarn add axios. You can use any HTTP library that works with Node.js, the native Node.js HTTP package, or an SDK library of an API provider.
The action extension in this example receives a URL parameter search and uses it to find people in the Star Wars API. The result is proxied back to the requesting browser.

2. Use and test the action

Every action is exposed through a URL that follows the schema /frontastic/action/<namespace>/<name>. The example action can be reached at /frontastic/action/star-wars/character. For example, our Frontend component tsx could look like the below:
frontend/star-wars/character-search/index.tsxtypescript
import React, { useState } from 'react';
import classnames from 'classnames';
import { sdk } from 'sdk';

type Character = {
  name: string;
  height: string;
  mass?: string;
  hairColor?: string;
  eyeColor: string;
  birthYear?: string;
  skinColor?: string;
  gender?: string;
  homeworld: string;
  films?: any;
  species?: any;
  vehicles?: any;
  starships?: any;
  created: string;
  edited: string;
  url: string;
};

type Props = {
  data: Character[];
};

const StarWarsCharacterSearch: React.FC<Props> = ({ data }) => {
  const [inputText, setInputText] = useState('');
  const [results, setResults] = useState(data);

  const handleSearchCharacter = () => {
    sdk
      .callAction({
        actionName: 'star-wars/character',
        query: { search: inputText },
      })
      .then((response) => {
        setResults(response.data.allPeople.people);
      });
  };

  return (
    <>
      <div className="w-full max-w-xs">
        <div className="md:flex md:items-center mb-6">
          <div className="md:w-2/3">
            <input
              id="character"
              type="text"
              placeholder="Character"
              value={inputText}
              onChange={(e) => {
                setInputText(e.target.value);
              }}
              className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
            ></input>
          </div>
          <div className="md:w-1/3">
            <button
              onClick={handleSearchCharacter}
              className="ml-4 bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
              type="button"
            >
              Search
            </button>
          </div>
        </div>
      </div>

      {results.length > 0 && (
        <div className="bg-white shadow overflow-hidden sm:rounded-lg">
          <div className="bg-gray-50 px-4 py-5 grid grid-cols-7 sm:gap-4 sm:px-6">
            <div className="text-sm font-medium text-gray-500">Name</div>
            <div className="text-sm font-medium text-gray-500">Height</div>
            <div className="text-sm font-medium text-gray-500">Eye color</div>
          </div>

          {results.map((character, i) => (
            <div key={i} className="border-t border-gray-200">
              <div
                className={classnames(
                  'px-4 py-5 grid grid-cols-7 sm:gap-4 sm:px-6',
                  {
                    'bg-gray-50': i % 2 === 1,
                  }
                )}
              >
                <div className="mt-1 text-sm text-gray-900 sm:mt-0">
                  {character.name}
                </div>
                <div className="mt-1 text-sm text-gray-900 sm:mt-0">
                  {character.height}
                </div>
                <div className="mt-1 text-sm text-gray-900 sm:mt-0">
                  {character.eyeColor}
                </div>
              </div>
            </div>
          ))}
        </div>
      )}

      {results.length === 0 && <div>Empty list</div>}
    </>
  );
};

export default StarWarsCharacterSearch;
You can test this action using a standard HTTP client. It's essential to send the Accept: application/json header with your request. For example:
curl -X 'GET' -H 'Accept: application/json' -H 'Commercetools-Frontend-Extension-Version: STUDIO_DEVELOPER_USERNAME' 'https://EXTENSION_RUNNER_HOSTNAME/frontastic/action/star-wars/character?search=skywalker'
For information on the Commercetools-Frontend-Extension-Version header and the extension runner hostname, see Main development concepts.

Calling an action

To trigger an operation on a backend system or fetch data asynchronously after initial rendering, call an action extension. For more information, see Developing an action extension.

For this example, we'll use the following Frontend component to implement searching a backend data set using an action:

Implementing the character search Frontend componenttypescript
import React from 'react';

const CharacterSearchTastic: React.FC = () => {
  return (
    <>
      <form
        onSubmit={(event) => {
          event.preventDefault();
        }}
      >
        <label>
          Search:
          <input type="text" name="search" />
        </label>
        <input type="submit" value="Search" />
      </form>
      <ul></ul>
    </>
  );
};

export default CharacterSearchTastic;
Character search Frontend component schemajson
{
  "tasticType": "example/character-search",
  "name": "Character search",
  "category": "Example",
  "description": "A frontend component showing actions and session handling",
  "schema": []
}

The Frontend component doesn't provide any configuration to the Studio and only renders a rudimentary search form and an empty list.

Perform the fetch call

An action extension is a server function in the API hub that can be invoked by a URL in the following format: https://<frontastic-host>/frontastic/action/<namespace>/<action>. For more information about action endpoints, see Action.
To communicate with your custom action extensions, use the frontend SDK's callAction method as it automatically resolves the correct API hub host for you, maintains the session, and provides other configuration options.
Implementing the character search action calltypescript
import { sdk } from 'sdk';

export const characterSearch = async (search: string) => {
  sdk
    .callAction({
      actionName: 'star-wars/character',
      query: { search: encodeURIComponent(search) },
    })
    .then((response) => {
      setResults(response.data.allPeople.people);
    });
};

Send custom headers

You can send only the coFE-Custom-Configuration custom header with your request to the API hub; all other custom headers are ignored by the API hub. You can pass a string value to this header using the SDK's callAction customHeaderValue parameter, as demonstrated in the following example:
An example of sending custom headers to actionsts
import { sdk } from 'sdk';

export const characterSearch = async (search: string) => {
  return await sdk
    .callAction({
      actionName: 'star-wars/character',
      query: { search: encodeURIComponent(search) },
      customHeaderValue: 'header-value-as-string',
    })
    .then((response) => {
      setResults(response.data.allPeople.people);
    });
};

It is not possible to set a response header from your extensions.