Level 1 · Lesson 4
Call the supplier
Level 1 · Lesson 4Write a small module that asks API Ninjas for a recipe, have the resolver use it, let the card ask for it, and put the supplier on the app’s approved list.
Behind, or starting here? Download the code as it should be before this lesson.
This is the round trip the whole course is built on. The card asks for a dish, the cook in the cloud takes the key from the safe and buys the recipe from the supplier, and the card shows it. Play it once before you start; each step below builds one movement.
The card calls invoke('getDish').
In the kitchen: The card sends the runner to the cook.
Runs in the browser · Front of house
Issue panel The recipe card
A module that adds your UI to the work item view, below the description.
A card clipped to every dish ticket, filled in by your kitchen.
Select any box in the diagram to see what it is.
For now the cook always asks for Pad thai. Lesson 6 makes it read the dish from the ticket.
Step 1: Add Forge’s API package
Locked until the step before it is done.
Functions reach the outside world through @forge/api, Forge’s own package for fetch and the product APIs. Install it in the app folder:
npm install @forge/apinpm writes this line into package.json for you:
package.jsonnpm install @forge/api writes this line for you.
"license": "MIT", |
"private": true, |
"dependencies": { |
Added: "@forge/api": "^8.2.0", |
"@forge/bridge": "^7.1.0", |
"@forge/react": "^12.3.0", |
"@forge/resolver": "^2.0.0", |
Why: External API
Stuck? “Cannot find module @forge/…” · I added an npm package
Next step: next
Step 2: Write the supplier module
Locked until the step before it is done.
Outside Atlassian · Suppliers
External API · a supplier
An external API is any service outside Atlassian that your functions call over HTTPS. Here it is API Ninjas: you send a dish name and your key in the X-Api-Key header, and it answers with a list of matching recipes as JSON. Only functions call it, never the browser, so the key never leaves the cloud.
In the kitchen The supplier sends what your kitchen can’t make itself, such as a recipe for the dish on the ticket.
Create the folder src/supplier/ and a new file in it. It knows how to talk to API Ninjas and nothing else:
src/supplier/apiNinjas.jsA new file that knows how to talk to API Ninjas.
Added: import { fetch } from '@forge/api'; |
Added: |
Added: // The supplier: API Ninjas' recipe API. The caller passes the key in after reading |
Added: // it from an encrypted variable, so it never appears in code or in the browser. |
Added: // unverified: response fields (title, ingredients separated by "|", servings, |
Added: // instructions) and free-tier limits, from API Ninjas' public docs. |
Added: const RECIPE_URL = 'https://api.api-ninjas.com/v1/recipe'; |
Added: |
Added: export async function findDish(query, apiKey) { |
Added: if (!apiKey) { |
Added: throw new Error('No API Ninjas key. Set it with forge variables set --encrypt, then deploy again.'); |
Added: } |
Added: const params = new URLSearchParams({ query }); |
Added: const response = await fetch(`${RECIPE_URL}?${params}`, { |
Added: headers: { 'X-Api-Key': apiKey }, |
Added: }); |
Added: if (response.status === 429) { |
Added: throw new Error('The supplier is busy (rate limit). Try again in a minute.'); |
Added: } |
Added: if (!response.ok) { |
Added: throw new Error(`The supplier answered ${response.status}.`); |
Added: } |
Added: const recipes = await response.json(); |
Added: if (!Array.isArray(recipes) || recipes.length === 0) return null; |
Added: return toDish(recipes[0]); |
Added: } |
Added: |
Added: // Turn the supplier's format into the one the card uses. |
Added: function toDish(recipe) { |
Added: return { |
Added: title: String(recipe.title ?? ''), |
Added: servings: String(recipe.servings ?? ''), |
Added: ingredients: String(recipe.ingredients ?? '') |
Added: .split('|') |
Added: .map((item) => item.trim()) |
Added: .filter(Boolean), |
Added: method: String(recipe.instructions ?? ''), |
Added: }; |
Added: } |
Why: External API · Environment variable
Three things to notice:
findDishdoes not read the key itself. Whoever calls it passes the key in, so the module never knows where secrets live.- The answer is always a list, even with one match.
recipes[0]is the best match, and an empty list means the supplier has no such dish. toDishturns the supplier’s format into the card’s own: a title, servings, a list of ingredients and a method. If you ever switch suppliers, this file is the only one that changes. Level 2 reuses it as it is.
Stuck? I changed code in src/
Next step: next
Step 3: Teach the cook a new dish
Locked until the step before it is done.
The template’s resolver answers getText. Replace it with getDish, which takes the key from the safe and asks the supplier:
src/resolvers/index.js- Bring in the supplier module.
- Replace getText with getDish, which asks the supplier.
import Resolver from '@forge/resolver'; |
Added: import { findDish } from '../supplier/apiNinjas'; |
|
const resolver = new Resolver(); |
|
Removed: resolver.define('getText', (req) => { |
Removed: console.log(req); |
Removed: return 'Hello, world!'; |
Added: resolver.define('getDish', async () => { |
Added: const query = 'pad thai'; |
Added: const dish = await findDish(query, process.env.API_NINJAS_KEY); |
Added: return { query, dish }; |
}); |
|
export const handler = resolver.getDefinitions(); |
Why: Resolver · Environment variable
process.env.API_NINJAS_KEY is the variable from lesson 3. The resolver returns both the query and the dish, so the card can say what it looked for when the supplier finds nothing.
Stuck? The card says “No API Ninjas key”
Next step: next
Step 4: Send the runner
Locked until the step before it is done.
The card asks for getDish when it opens and fills in the sample layout with the answer:
src/frontend/index.jsx- Bring back the hooks and invoke.
- Ask for getDish when the card opens.
- Show what came back.
Removed: import React from 'react'; |
Added: import React, { useEffect, useState } from 'react'; |
import ForgeReconciler, { Heading, Inline, Lozenge, Stack, Text } from '@forge/react'; |
Added: import { invoke } from '@forge/bridge'; |
|
const App = () => { |
Added: const [result, setResult] = useState(null); |
Added: useEffect(() => { |
Added: invoke('getDish').then(setResult); |
Added: }, []); |
Added: const dish = result?.dish; |
return ( |
<Stack space="space.200"> |
<Inline space="space.100" alignBlock="center"> |
Removed: <Heading size="medium">Pad thai</Heading> |
Removed: <Lozenge>2 servings</Lozenge> |
Added: <Heading size="medium">{dish ? dish.title : 'Loading...'}</Heading> |
Added: {dish && <Lozenge>{dish.servings}</Lozenge>} |
</Inline> |
Removed: <Text>The supplier’s recipe goes here.</Text> |
Added: <Text>{dish ? dish.method : ''}</Text> |
</Stack> |
); |
}; |
Why: invoke()
Until the answer arrives, dish is empty, so the heading says Loading… for a moment. Lesson 5 replaces that with a proper waiting state.
Next step: next
Step 5: Put the supplier on the approved list
Locked until the step before it is done.
Declared in manifest.yml · The licence
Egress permission · the approved supplier list
Forge blocks every call from a function to an outside host unless the manifest lists it. Backend calls go under permissions.external.fetch.backend. List the host name only, without https:// or a path. Each host you add is shown to the admin who installs the app.
In the kitchen The back door only opens for suppliers on the approved list.
Atlassian docs: Runtime egress permissions (opens in a new tab)
Without this, the code is right and the call still fails. Add the host at the end of manifest.yml:
manifest.ymlAllow calls to API Ninjas from your functions.
memoryMB: 256 |
architecture: arm64 |
id: ari:cloud:ecosystem::app/<your-app-id> |
Added: permissions: |
Added: external: |
Added: fetch: |
Added: backend: |
Added: - address: api.api-ninjas.com |
Why: Egress permission · Major version
Stuck? The logs show a fetch to api.api-ninjas.com was blocked
Next step: next
Step 6: Deploy and sign the new licence
Locked until the step before it is done.
Declared in manifest.yml · The licence
Major version · a new licence
A deploy that asks for more than the last version could, such as a new outside host or a new scope, becomes a new major version. The CLI stops and asks you to approve it, so nobody widens an app’s permissions by accident. Code-only changes stay minor and reach sites by themselves.
In the kitchen Asking for more rights means a new licence, which the landlord has to sign.
Atlassian docs: Environments and versions (opens in a new tab)
Lives on your laptop · Home kitchen
forge install --upgrade · the landlord signs the new licence
forge install --upgrade moves an existing installation onto the newest major version, so the site grants the new permissions. Until you run it, the site keeps running the old version with the old permissions, even though the deploy succeeded.
In the kitchen Until the landlord signs, the location keeps working under the old licence.
Stop the tunnel if it is running, then deploy:
forge deployIt stops on purpose and names the rule to approve. Approve it, then upgrade the installation on your site:
forge deploy --approve MAJOR_VERSION_RULE
forge install --upgradePick the installation on your developer site and confirm.
$ forge install --upgrade ? Select the site or workspace to upgrade: your-name.atlassian.net (Jira) ✔ Upgrade complete!
Stuck? Deploy stops after a permission change · The tunnel is running while I deploy or upgrade · `forge install` warns about installing a development app on a production site
Next step: next
Step 7: Open the Pad thai ticket
Locked until the step before it is done.
Reload the Pad thai ticket. After a moment the card shows the supplier’s title for the dish, its servings, and the method.
Recipe card
Pad Thai 4 servings
Soak the noodles in warm water, then stir-fry with the sauce, egg and bean sprouts…
If the heading stays on Loading…, the call failed. forge logs shows why; lesson 5 makes the card say it too.
Stuck? The card says “No API Ninjas key” · The card says “The supplier answered 401” (or 403) · The card says “The supplier is busy” · The logs show a fetch to api.api-ninjas.com was blocked
Next step: next
Compare with yours: download the code after this lesson.