PlatformEngr® Backstage plugins course

Module 06 · The frontend plugin · 40 min · You are: Platform team

Talk to the backend: a utility API

In this module

  • You learn: How utility APIs are declared (createApiRef), implemented and provided (ApiBlueprint), and how discoveryApi and fetchApi call your backend as the signed-in user.
  • You do: Write the client, provide it, copy the table page, and follow one request in the browser's network tab.
  • You end with: /readiness lists every scorecard, lowest score first, fetched with your sign-in token. Merged.

Components should not build URLs or attach tokens. A utility API hides that behind an interface, so tests can swap it for a fake and another app can swap it for a different client. The core APIs do the hard part: where the backend is, and who the user is.

Open this module in the interactive course to track progress.

Concepts

API ref
createApiRef<ReadinessApi>({ id }) names an interface. Components ask for it with useApi(readinessApiRef). Utility APIs.
ApiBlueprint
An extension that provides an implementation, built from other APIs. Creating APIs.
discoveryApi
Gives the base URL of a backend plugin, http://localhost:7007/api/readiness on your laptop.
fetchApi
fetch with the user's Backstage token added. The backend sees the user, so permissions apply.

Files to know

Open them in your editor or on GitHub; the bullets say what to look at.

Steps

1. Write the client

It returns undefined for a 404, because "not evaluated yet" is a normal state for the card in module 07.

cd ~/work/portal && yarn --cwd plugins/readiness add @backstage/catalog-model@^1.10.2 @backstage/errors@^1.3.2 react-use@^17.2.4 @internal/backstage-plugin-readiness-common@workspace:^

Create ~/work/portal/plugins/readiness/src/api.ts

import {
  createApiRef,
  DiscoveryApi,
  FetchApi,
} from '@backstage/frontend-plugin-api';
import { parseEntityRef } from '@backstage/catalog-model';
import { ResponseError } from '@backstage/errors';
import type { Scorecard } from '@internal/backstage-plugin-readiness-common';

/** What the frontend can ask the readiness backend. */
export interface ReadinessApi {
  getScorecard(entityRef: string): Promise<Scorecard | undefined>;
  listScorecards(): Promise<Scorecard[]>;
  evaluate(entityRef: string): Promise<Scorecard>;
}

export const readinessApiRef = createApiRef<ReadinessApi>({
  id: 'plugin.readiness',
});

/** Calls the readiness backend with the signed-in user's token. */
export class ReadinessClient implements ReadinessApi {
  constructor(
    private readonly discoveryApi: DiscoveryApi,
    private readonly fetchApi: FetchApi,
  ) {}

  private async request(path: string, init?: RequestInit) {
    const baseUrl = await this.discoveryApi.getBaseUrl('readiness');
    const response = await this.fetchApi.fetch(`${baseUrl}${path}`, init);
    if (!response.ok) {
      throw await ResponseError.fromResponse(response);
    }
    return response.json();
  }

  async getScorecard(entityRef: string) {
    const { kind, namespace, name } = parseEntityRef(entityRef);
    try {
      return await this.request(`/scorecards/${kind}/${namespace}/${name}`);
    } catch (e) {
      if (e instanceof ResponseError && e.statusCode === 404) {
        return undefined;
      }
      throw e;
    }
  }

  async listScorecards() {
    return (await this.request('/scorecards')).items;
  }

  async evaluate(entityRef: string) {
    return this.request('/evaluate', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ entityRef }),
    });
  }
}

2. Provide it and use it

The plugin now has two extensions. The page is a table from Backstage UI; copy it and read it in the reference.

Replace the whole file ~/work/portal/plugins/readiness/src/plugin.tsx

import {
  ApiBlueprint,
  createFrontendPlugin,
  discoveryApiRef,
  fetchApiRef,
  PageBlueprint,
} from '@backstage/frontend-plugin-api';
import { readinessApiRef, ReadinessClient } from './api';
import { rootRouteRef } from './routes';

/** How the app gets a ReadinessApi: one client, built from two core APIs. */
export const readinessApi = ApiBlueprint.make({
  params: defineParams =>
    defineParams({
      api: readinessApiRef,
      deps: { discoveryApi: discoveryApiRef, fetchApi: fetchApiRef },
      factory: ({ discoveryApi, fetchApi }) =>
        new ReadinessClient(discoveryApi, fetchApi),
    }),
});

/** /readiness: every scorecard. The title puts it in the sidebar. */
export const readinessPage = PageBlueprint.make({
  params: {
    path: '/readiness',
    routeRef: rootRouteRef,
    title: 'Readiness',
    loader: () =>
      import('./components/ReadinessPage').then(m => <m.ReadinessPage />),
  },
});

export const readinessPlugin = createFrontendPlugin({
  pluginId: 'readiness',
  extensions: [readinessApi, readinessPage],
  routes: {
    root: rootRouteRef,
  },
});
cp ~/work/backstage-workshop/portal/plugins/readiness/src/components/ReadinessPage.tsx ~/work/portal/plugins/readiness/src/components/ReadinessPage.tsx
cd ~/work/portal && yarn tsc

3. See it, and follow the request

Reload /readiness. Every component is listed with its score and failing checks.

Open the developer tools, tab Network, and reload. Find the request to /api/readiness/scorecards. Its Authorization header carries your token: fetchApi added it.

4. Merge it

cd ~/work/portal

Propose it as a pull request. Put it on its own branch and push it:

git switch -c readiness-api && git add -A && git commit -qm "Readiness: API client and scorecard page"
git push -qu origin HEAD

Open the pull request. It prints a link:

gh pr create --fill

Open that link. Files changed shows exactly your diff.

Merge it on GitHub (Squash and merge, then Delete branch), or from the terminal:

gh pr merge --squash --delete-branch

Bring your copy up to date:

git switch -q main && git pull -q

Simplified for the course

The table shows every scorecard on one page. A large catalog needs paging in the API and the table.

Break it on purpose: An API nobody provides

During a refactor the extensions list is tidied and the API extension falls out. TypeScript is happy.

You practise: the difference between declaring an API and providing it.

1. Break it

Change in ~/work/portal/plugins/readiness/src/plugin.tsx
The tidy-up.

-   extensions: [readinessApi, readinessPage],
+   extensions: [readinessPage],

Save and reload /readiness.

2. What you see

Expected: the table. You get: an error on the page: NotImplementedError: No implementation available for apiRef{plugin.readiness}.

3. Fix it yourself

Make the app provide the API again. You are done when the table is back.

Hint 1

The ref (readinessApiRef) exists; which extension turns it into a working client?

Hint 2

Search the Visualizer for api:readiness.

The fix

Change in ~/work/portal/plugins/readiness/src/plugin.tsx

-   extensions: [readinessPage],
+   extensions: [readinessApi, readinessPage],
After you fixed it: the five questions
What failed?
The app had the API ref but no extension that provides it, so useApi threw.
How was it detected?
The error on the page, naming the API id.
Who responds?
The platform team.
How is it corrected?
Put the API extension back in the plugin.
How is it prevented?
A test that renders the page with the real plugin, or a look at the Visualizer: api:readiness must be in the tree.

Quiz

  1. Why does the client use fetchApi instead of the browser's fetch?
    • It adds the signed-in user's token, so the backend knows who calls
    • It is faster, because it caches every response in the browser
    • It retries failed requests until the backend answers 200
  2. How does the client know where the readiness backend is?
    • The URL is written in the client: http://localhost:7007/api/readiness
    • discoveryApi.getBaseUrl('readiness'), from the app's config
    • The page passes it in as a prop from app-config.yaml
  3. How do you test the page without a backend?
    • Start the backend in the test with startTestBackend
    • Mock the browser's fetch for every URL the page calls
    • Provide a fake ReadinessApi for readinessApiRef in the test
Answers
  1. It adds the signed-in user's token, so the backend knows who calls Without the token every call would be a 401.
  2. discoveryApi.getBaseUrl('readiness'), from the app's config Discovery uses backend.baseUrl, so the same code works on your laptop and in production.
  3. Provide a fake ReadinessApi for readinessApiRef in the test Components depend on the interface, so a fake is one object.

Field note

I give every plugin its client first and its components second. The interface forces the question "what does the UI actually need?", and the answer is usually smaller than the backend's API.

Report a problem with this module