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)

You can think of an action roughly as a controller that's run for you inside the API hub.

1. Implement the action

Actions are categorized into namespaces for clarity. Namespaces are created by nesting objects in the index.ts:

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>. So the example action can be reached at /frontastic/action/star-wars/character. For example, our Frontend component tsx could look like the below:

import React, { useState } from 'react';
import classnames from 'classnames';
import { fetchApiHub } from 'frontastic/lib/fetch-api-hub';
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 = () => {
fetchApiHub(`/action/star-wars/character?search=${inputText}`).then(
(data) => {
setResults(data.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.

Further reading