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 howdiscoveryApiandfetchApicall 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:
/readinesslists 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 withuseApi(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/readinesson your laptop. - fetchApi
fetchwith 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.
portal/plugins/readiness/src/api.ts(reference, your repo): The interface, its ref and the client.portal/plugins/readiness/src/plugin.tsx(reference, your repo): How the app gets one.- Built from two core APIs L17–25
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
useApithrew. - 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:readinessmust be in the tree.
Quiz
- Why does the client use
fetchApiinstead of the browser'sfetch?- 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
- 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
- The URL is written in the client:
- How do you test the page without a backend?
- Start the backend in the test with
startTestBackend - Mock the browser's
fetchfor every URL the page calls - Provide a fake
ReadinessApiforreadinessApiRefin the test
- Start the backend in the test with
Answers
- It adds the signed-in user's token, so the backend knows who calls Without the token every call would be a 401.
discoveryApi.getBaseUrl('readiness'), from the app's config Discovery usesbackend.baseUrl, so the same code works on your laptop and in production.- Provide a fake
ReadinessApiforreadinessApiRefin 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.