LAB GUIDE / SECTION 04
Mission 2 — Your character
Goal: your Droid ID card fills in with your Character's facts, and the page takes on the look of their homeworld.
Platform concepts: calling an external API with fetch, chaining two requests, fallbacks, edge caching.
4.1 What getCharacter does#
Your page asks one function for everything it shows: getCharacter(id) in src/character.js. Right now it returns a placeholder, which is why the card says "unknown":
export async function getCharacter(id) {
// Mission 2: fetch the real character from SWAPI
return PLACEHOLDER;
}You'll replace it with a lookup in SWAPI, the free Star Wars API. Open https://swapi.dev/api/people/14/ in your browser to see what comes back: a name, a height, birth_year... and homeworld, which is a URL, not a planet. To get the planet, you need a second request.
Your page and, later, your Droid both call getCharacter(env.CHARACTER_ID). So it must return exactly this shape:
{
name: "Luke Skywalker", // string
height: "172", // string | null (cm)
mass: "77", // string | null (kg)
birthYear: "19BBY", // string | null
gender: "male", // string | null
homeworld: { // an object, or null if there is no homeworld
name, climate, terrain, population // all strings or null
},
films: ["A New Hope", "..."], // up to 3 film titles
}SWAPI says "unknown" (or "n/a") when it doesn't know something. Your function turns those into null.
4.2 Write getCharacter#
The comments at the top of src/character.js list the steps. They're the spec. Follow them in order:
fetchhttps://swapi.dev/api/people/{id}/with{ cf: { cacheTtl: 86400, cacheEverything: true } }as the second argument (see the tip below).- If the response is not OK, or
fetchthrows, tryhttps://swapi.info/api/people/{id}instead (no trailing slash). swapi.dev is sometimes down, so you have a fallback. - Make a second
fetch:person.homeworldis a URL, so fetch it for the planet details. Only do this if the URL is onswapi.devorswapi.info. Never fetch a URL you don't trust. - Map the fields. SWAPI uses snake_case (
birth_year), you return camelCase (birthYear). Turn"unknown"and"n/a"intonull.person.filmsis a list of URLs: the number at the end is the film id, and theFILM_TITLEStable in the file turns it into a title. Keep up to 3. - Never throw. On any failure,
return PLACEHOLDER. A broken API must not break your page or your Droid.
Save and refresh http://localhost:8787. Then git push.
Tip
In real projects, API keys are secrets. Many APIs want a key. You never put it in your code or your repo. On your laptop, a key goes in a file called
.dev.vars, which stays out of git. In production, you store it withnpx wrangler secret put MY_KEY, and your code reads it asenv.MY_KEYin both places. SWAPI is open, so today's class needs no keys. You'll meet this the first time an API asks for one.
Tip
Why
cacheTtl? A Star Wars character never changes, so withcacheTtl: 86400Cloudflare keeps SWAPI's answer in its cache for 24 hours. Most visitors never wait for SWAPI. Load your page twice and compare: the second load should be much faster. It also means a SWAPI hiccup doesn't hurt your page while the answer is cached.
Check#
- Your ID card shows your Character's name, height, homeworld, climate, terrain and films
- The hint box ("Character: unknown…") is gone
- The page theme matches the homeworld, and changes when you change
CHARACTER_ID(try20for space or71for ice, then set yours back) - After
git push, the live URL shows it too
Show solution
Replace the whole of src/character.js with this:
// SOLUTION for Mission 2. Teacher's reference: copy over src/character.js.
// Returns { name, height, mass, birthYear, gender, homeworld: { name, climate, terrain, population } | null, films }
// and never throws: on any failure it returns PLACEHOLDER.
// SWAPI's film ids, so we don't need a fetch per film.
const FILM_TITLES = {
1: "A New Hope",
2: "The Empire Strikes Back",
3: "Return of the Jedi",
4: "The Phantom Menace",
5: "Attack of the Clones",
6: "Revenge of the Sith",
};
const PLACEHOLDER = {
isPlaceholder: true,
name: "Unknown Droid",
height: null,
mass: null,
birthYear: null,
gender: null,
homeworld: null,
films: [],
};
// SWAPI data never changes, so let Cloudflare cache every request for 24 hours.
const CACHE = { cf: { cacheTtl: 86400, cacheEverything: true } };
// GET a URL and return its JSON, or null if anything goes wrong.
async function getJson(url) {
try {
const res = await fetch(url, { ...CACHE, signal: AbortSignal.timeout(2500) });
if (!res.ok) return null;
return await res.json();
} catch (err) {
console.log("SWAPI request failed:", url, err.message);
return null;
}
}
// Only follow links to the SWAPI hosts.
function isSwapiUrl(value) {
try {
const { protocol, hostname } = new URL(value);
return protocol === "https:" && (hostname === "swapi.dev" || hostname === "swapi.info");
} catch {
return false;
}
}
// SWAPI says "unknown" (or "n/a") when it doesn't know.
function clean(value) {
if (value === undefined || value === null) return null;
const text = String(value).trim();
return text === "" || text === "unknown" || text === "n/a" ? null : text;
}
export async function getCharacter(id) {
try {
const safeId = encodeURIComponent(String(id ?? "").trim());
if (!safeId) return PLACEHOLDER;
// 1 + 2: swapi.dev first, swapi.info as the fallback
let person = await getJson(`https://swapi.dev/api/people/${safeId}/`);
if (!person?.name) person = await getJson(`https://swapi.info/api/people/${safeId}`);
if (!person?.name) return PLACEHOLDER;
// 3: the second fetch, for the homeworld
let homeworld = null;
if (isSwapiUrl(person.homeworld)) {
const planet = await getJson(person.homeworld);
if (planet) {
homeworld = {
name: clean(planet.name),
climate: clean(planet.climate),
terrain: clean(planet.terrain),
population: clean(planet.population),
};
}
}
// 4: the film id is the number at the end of each film URL
const films = (Array.isArray(person.films) ? person.films : [])
.map((url) => FILM_TITLES[String(url).match(/(\d+)\/?$/)?.[1]])
.filter(Boolean)
.slice(0, 3);
return {
name: person.name,
height: clean(person.height),
mass: clean(person.mass),
birthYear: clean(person.birth_year),
gender: clean(person.gender),
homeworld,
films,
};
} catch (err) {
// 5: never throw
console.log("getCharacter failed:", err.message);
return PLACEHOLDER;
}
}The solution adds a few safety nets on top of the steps: a 2.5 s timeout per request, so a slow SWAPI can't hold your page, and encodeURIComponent on the id.
Still the placeholder?
- Look at the terminal running
npm run dev, whereconsole.logoutput appears. In production, runnpx wrangler tailwhile you reload the page. - SWAPI may be down. If both swapi.dev and swapi.info fail, the placeholder comes back even with correct code. Check
npx wrangler tailfor "SWAPI request failed" lines, wait a minute, and refresh. Raise your hand if it lasts. CHARACTER_IDof17doesn't exist, and anything above 83 doesn't either. Pick another one.- The card shows
nullfields as "unknown". If only a few rows are "unknown", that's SWAPI not knowing (Yoda has no known homeworld). - Still stuck after 5 minutes? Open "Show solution", paste it, and keep going.
Stretch
Add a
console.logbefore and after eachfetchand compare the first load with the second. Or try every Character in the table above and see all the themes.
Next: Checkpoint
Checks are saved automatically on this browser.