Lab GuideYour character
14:30 17:00

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":

js
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:

js
{
  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:

  1. fetch https://swapi.dev/api/people/{id}/ with { cf: { cacheTtl: 86400, cacheEverything: true } } as the second argument (see the tip below).
  2. If the response is not OK, or fetch throws, try https://swapi.info/api/people/{id} instead (no trailing slash). swapi.dev is sometimes down, so you have a fallback.
  3. Make a second fetch: person.homeworld is a URL, so fetch it for the planet details. Only do this if the URL is on swapi.dev or swapi.info. Never fetch a URL you don't trust.
  4. Map the fields. SWAPI uses snake_case (birth_year), you return camelCase (birthYear). Turn "unknown" and "n/a" into null. person.films is a list of URLs: the number at the end is the film id, and the FILM_TITLES table in the file turns it into a title. Keep up to 3.
  5. 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 with npx wrangler secret put MY_KEY, and your code reads it as env.MY_KEY in 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 with cacheTtl: 86400 Cloudflare 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 (try 20 for space or 71 for 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:

js
// 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, where console.log output appears. In production, run npx wrangler tail while 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 tail for "SWAPI request failed" lines, wait a minute, and refresh. Raise your hand if it lasts.
  • CHARACTER_ID of 17 doesn't exist, and anything above 83 doesn't either. Pick another one.
  • The card shows null fields 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.log before and after each fetch and 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.

Your project

Pick your Droid Name first. Add the rest when you know it; every field is optional. These details stay in this browser and are never sent anywhere.

A nickname, not your full name. Used for your Student Worker and repo. Lowercase letters, numbers and hyphens; up to 40 characters.

The workers.dev subdomain you choose at signup. Same naming rules as your Droid Name.

Add this later, or paste a GitHub repo URL to fill your username and Droid Name.

My URL is different

Optional. Use your public https://…workers.dev URL. Paths and trailing slashes are removed. Leave blank to use your Droid Name and subdomain.

LIVE PREVIEW

Your droid will live at https://<your-droid-name>.<your-subdomain>.workers.dev

Your repo: github.com/<your-github-user>/<your-droid-name>