Skip to content

API Modeling

To streamline our communication with the backend API, we have a set of practices for modeling endpoints. This way you can easily generate all needed files using a Swagger schema with an AI tool and only tweak the generated code.

Each endpoint should have its own file, usually placed in the src/modules/*/api/action-name directory.

The endpoint folder usually contains three files: action-name.models.ts, action-name.request.ts, and action-name.query.ts/action-name.mutation.ts.

action-name.models.ts

This file defines what the API takes/returns and what we want to send/receive.

Models for what the API needs/returns:

  • ActionNameApiBody - body of the API request.
  • ActionNameApiParams - path params of the API request.
  • ActionNameApiSearch - search params of the API request.
  • ActionNameApiResponse - success response of the API request.
  • ActionNameApiError - error response of the API request.

Models for what we send/receive:

  • ActionNameData - data shape we pass as an input when making a request.
  • ActionNameResponse - data shape we get as a successful request output.

Mappers:

  • mapApiData - uses createApiDataMapper to map our input to the data the API expects.
  • mapApiResponse - function that maps the API success response to the output we expect.

You don't need to define everything

Each models file should contain only the models and mappers it actually uses.

For example, if the API response is exactly what we want to use on our side, there will be no ActionNameResponse or mapApiResponse in the file.

action-name.request.ts

This file sends the request to the API and handles the response.

The endpoint path (with path params if required) should be defined as a constant using the apiEndpoint function.

Then the request function should be defined as follows:

  1. It should receive only one argument - the data of type EndpointData. If there is no input to pass, it should receive no arguments.
  2. If it receives the data, it should call mapApiData to map it to the data the API expects.
  3. If the endpoint receives params, they should be interpolated using the addPathParams function.
  4. It should call makeRequest to send the request to the API.
  5. If there are any expected errors (we need them to give the user feedback), they should be returned in the callback passed as the second argument to makeRequest.
  6. It should return the result of the parseResponse call, passing the response data, API response model, and optionally the mapper. If there is no response, just return ok(null).
ts
// Endpoint path definition (with path param)
export const ACTION_NAME_ENDPOINT = apiEndpoint("/some/path/:withParam");

// 1. Receive the input
export async function endpointRequest(data: EndpointData) {
  // 2. Map the input to the data the API expects
  const { params, searchParams, json } = mapApiData(data);

  // 3. Interpolate the path params
  const endpoint = addPathParams(ACTION_NAME_ENDPOINT, params);

  // 4. Send the request
  const response = await makeRequest(
    apiClient.post(endpoint, { searchParams, json }).json(),
    // 5. Handle the expected errors
    async (ex) => {
      // Filter out non-http errors
      if (!isHttpError(ex)) return;

      // Parse HTTP error from unknown type to our API error
      const apiError = await toApiError(ex);

      // Return the API error if it's one of the expected ones
      if (ActionNameApiError.is(apiError)) return apiError.error;
  });

  // If there is an error, return it
  if (isErr(response)) return response;

  // 6. Parse the response
  return parseResponse(response.data, EndpointApiResponse, mapApiResponse);
}

action-name.query.ts/action-name.mutation.ts

This is the only file we use in the rest of the project (its content should be exported from the index.ts file). The file will be a query for GET requests and a mutation for other request types.

action-name.query.ts

If the request has no input, it should export an object constructed using the queryOptions function with at least queryKey and queryFn defined.

For requests with input, export a function that takes the input and returns the queryOptions mentioned above.

ts
export const actionNameQueryOptions = queryOptions<
  UnwrapOk<typeof actionNameRequest>,
  UnwrapErr<typeof actionNameRequest>
>({
  queryKey: ["action-name"],
  queryFn: toThrowable(actionNameRequest),
});
ts
export function actionNameQueryOptions(
  data: Parameters<typeof actionNameRequest>[0]
) {
  return queryOptions<
    UnwrapOk<typeof actionNameRequest>,
    UnwrapErr<typeof actionNameRequest>
  >({
    queryKey: ["action-name", data.someParam],
    queryFn: toThrowable(async () => actionNameRequest(data)),
  });
}

action-name.mutation.ts

While queries export only the options object, mutations should export the entire hook that uses useMutation within them.

The hook should take mutation options defined using the MutationOptions generic type and return the result of the useMutation call with at least mutationKey, mutationFn, and destructured options. In many cases, we also use useQueryClient within the mutation hook to update query data on mutation success.

ts
// Define the mutation options type based on the request function
export interface ActionNameMutationOptions
  extends MutationOptions<
    UnwrapOk<typeof actionNameRequest>,
    UnwrapErr<typeof actionNameRequest>,
    Parameters<typeof actionNameRequest>[0]
  > {}

// Create mutation hook
export function useActionNameMutation(options?: ActionNameMutationOptions) {
  // Get the query client if needed
  const queryClient = useQueryClient();

  // Return the mutation object
  return useMutation({
    mutationKey: ["action-name"],
    mutationFn: toThrowable(actionNameRequest),
    ...options,
    onSuccess: async (...args) => {
      // If some query needs to be refetched, invalidate it
      // You can also use .setQueryData if mutation returned the new state
      await queryClient.invalidateQueries(someQueryOptions);

      // Call the passed callback as it got overridden
      options?.onSuccess?.(...args);
    },
  });
}