Tutorial 29 of 54 · for anyone whose backend speaks GraphQL · Markdown

Call a GraphQL API

You'll read countries from a public GraphQL API through a contract the build checks, handle the call failing and give a client an API key the application code never sees. The API is https://countries.trevorblades.com/, which needs no key.

Before you start

Install from crates.io. You need no Node and no package manager.

cargo install snapfire_compiler
cargo install snapfire_fsr_cli
fsr --version

Every command and screenshot on this page was captured with fsr 0.x.

Start the app#

$ fsr new atlas

The pages here render on the server and place no island, so the app needs no framework.

Write the document#

A GraphQL client is one file, app/clients/<name>.graphql. It holds the schema's SDL followed by the named operations the app sends. You only need the part of the schema your operations reach:

app/clients/countries.graphql
type Query {
  continent(code: ID!): Continent
  country(code: ID!): Country
}

type Continent {
  code: ID!
  name: String!
  countries: [Country!]!
}

type Country {
  code: ID!
  name: String!
  native: String!
  capital: String
  currency: String
  emoji: String!
  continent: Continent!
  languages: [Language!]!
}

type Language {
  code: ID!
  name: String!
}

query Continent($code: ID!) {
  continent(code: $code) {
    name
    countries {
      code
      name
      emoji
    }
  }
}

query Country($code: ID!) {
  country(code: $code) {
    name
    native
    capital
    currency
    emoji
    languages {
      name
    }
  }
}

The file's name is the service's name, so this one is services.countries. Each operation is a method named in lowerCamel, so Country is services.countries.country. Its variables are the arguments.

Point the client at the endpoint#

config/app.toml
[clients.countries]
base_url = "https://countries.trevorblades.com/"

base_url is where every operation is posted. The .graphql extension picks the GraphQL transport, which posts the operation with the arguments as its variables and answers the response's data. Build and the report names it:

$ fsr build atlas/app --verbose
services  countries              graphql     clients/countries.graphql

Read the generated types#

Each answer is typed from the operation's selection, not from the whole schema:

app/generated/services.d.ts
export interface CountryQueryCountryLanguages {
  name: string;
}

export interface CountryQueryCountry {
  name: string;
  native: string;
  capital: string | null;
  currency: string | null;
  emoji: string;
  languages: CountryQueryCountryLanguages[];
}

export interface CountryQuery {
  country: CountryQueryCountry | null;
}

export interface Services {
  countries: {
    continent(args: { code: string; }): Promise<ContinentQuery>;
    country(args: { code: string; }): Promise<CountryQuery>;
  };
}

country is nullable because the schema says country(code: ID!): Country. capital is nullable for the same reason. Reading a field the operation didn't select is a type error:

$ fsr check atlas/app
routes/country/[code]/page.tsx(10,57): error TS2339: Property 'phone' does not exist on type 'CountryQueryCountry'.

The build also validates every operation against the schema. Misspell capital in the query and it stops there:

$ fsr check atlas/app
clients/countries.graphql: countries.graphql: Error: type `Country` does not have a field `capitol`

List a continent#

app/routes/page.loader.ts
import type { Ctx } from "@snapfire/fsr/host";

export async function load({ services }: Ctx<"/">) {
  const { continent } = await services.countries.continent({ code: "EU" });
  return { continent };
}
app/routes/page.tsx
import { Link } from "@snapfire/fsr/components";
import type { RootProps } from "@generated/client";

export default function Index({ continent }: RootProps) {
  if (continent === null) return <p>There is no such continent.</p>;
  return (
    <section>
      <h1>{continent.name}</h1>
      <ul>
        {continent.countries.map((c) => (
          <li key={c.code}>
            {c.emoji} <Link href={`/country/${c.code}`}>{c.name}</Link>
          </li>
        ))}
      </ul>
    </section>
  );
}

The page checks null before it reads a field, since the type says the answer can be null.

Show one country#

app/routes/country/[code]/page.loader.ts
import type { Ctx } from "@snapfire/fsr/host";
import { fail } from "@snapfire/fsr/host";

export async function load({ params, services }: Ctx<"/country/{code}">) {
  const { country } = await services.countries.country({ code: params.code });
  if (country === null) fail("not_found", `no country with the code ${params.code}`);
  return { country };
}
app/routes/country/[code]/page.tsx
import type { CountryCodeProps } from "@generated/client";

export default function Country({ country }: CountryCodeProps) {
  return (
    <article>
      <h1>
        {country.emoji} {country.name}
      </h1>
      <p>In its own language: {country.native}</p>
      <p>Capital: {country.capital ?? "none"}</p>
      <p>Currency: {country.currency ?? "none"}</p>
      <p>Languages: {country.languages.map((l) => l.name).join(", ")}</p>
    </article>
  );
}

Run fsr dev atlas/app and open /. You get Europe with 53 countries. Click France and you get Paris, EUR and French. Open /country/XX and you get a 404 with "no country with the code XX", since the API answers null for a code it doesn't know and the loader turns that into not_found. The page only renders a country that exists.

When the call fails#

A failed call has a kind. The GraphQL transport reads the first error's extensions.code:

extensions.codeKind
NOT_FOUNDnot_found
UNAUTHENTICATEDunauthorized
FORBIDDENforbidden
BAD_USER_INPUT, BAD_REQUEST, GRAPHQL_VALIDATION_FAILED, GRAPHQL_PARSE_FAILEDinvalid
none or anything elseinternal

A non-2xx answer without errors maps the way it does over HTTP. A backend that doesn't answer at all is unavailable.

Try that last one. Point the client at a port nothing listens on:

config/app.toml
[clients.countries]
base_url = "http://127.0.0.1:3429/"

Reload /. The loader failed, so the route's error component renders with a 503. Its message names countries.continent as unavailable.

The loader never saw a connection error. The transport turned it into a kind before the loader's await returned.

To answer the failure yourself, catch it in the loader and read its kind:

app/routes/page.loader.ts
import type { Ctx, Failure } from "@snapfire/fsr/host";

export async function load({ services }: Ctx<"/">) {
  try {
    const { continent } = await services.countries.continent({ code: "EU" });
    return { continent, failed: null };
  } catch (e) {
    return { continent: null, failed: (e as Failure).kind };
  }
}
app/routes/page.tsx
import { Link } from "@snapfire/fsr/components";
import type { RootProps } from "@generated/client";

export default function Index({ continent, failed }: RootProps) {
  if (failed !== null) return <p>The atlas is not answering ({failed}). Try again in a minute.</p>;
  if (continent === null) return <p>There is no such continent.</p>;
  return (
    <section>
      <h1>{continent.name}</h1>
      <ul>
        {continent.countries.map((c) => (
          <li key={c.code}>
            {c.emoji} <Link href={`/country/${c.code}`}>{c.name}</Link>
          </li>
        ))}
      </ul>
    </section>
  );
}

With the dead port, / now reads "The atlas is not answering (unavailable). Try again in a minute." Put base_url back when you're done.

Send an API key#

Many GraphQL APIs want a key that belongs to the application, not to a visitor. Declare it under [secrets] and name it on the client:

config/app.toml
[secrets.atlas]
description = "The atlas API key"

[clients.countries]
base_url = "https://countries.trevorblades.com/"
credential = { secret = "atlas", header = "x-api-key" }

Without header the key goes as Authorization: Bearer <value>. query = "key" sends it as a query parameter instead. The key sits on the client's transport, so no loader, trace or interceptor sees it. There is no argument a loader could pass it in.

The key needs a value or the host stops at boot:

`secrets.atlas.value` has no value and a client sends it; set C5_SECRETS__ATLAS__VALUE or write it encrypted in a configuration file the host reads

A key for your own machine can go in config/development.toml as plain text:

config/development.toml
[secrets.atlas]
value = "a-key-for-this-machine"

Outside development a plain-text value in a file stops the host, since that file is in the repository. A deployment sets C5_SECRETS__ATLAS__VALUE instead. fsr doctor tells you what the production ladder still expects:

$ fsr doctor atlas/app --env production
expects      session.key: the files hold the placeholder; this deployment needs C5_SESSION__KEY when it starts
expects      secrets.atlas.value: no file sets it; this deployment needs C5_SECRETS__ATLAS__VALUE when it starts
doctor       22 checks, nothing to report

A client on the mock transport sends nothing, so your tests run with no key at all.

Next up: 380. Embed a video.