Data from an API
Assignment
Martina Hostettler wants the requests to reach the association.
As a volunteer at the counter, I want to see the members' requests on the association's server, so that we can get the things ready.
The volunteer adds that the catalog should come from the server as well, so that a return at the counter reaches everyone. The acceptance criteria are:
- The catalog comes from the course API. The app shows that it is loading and reports errors clearly.
- A request goes to the course API, and the app shows its answer, a refusal included.
- The overview "My requests" loads from the course API and cancels the loading when someone leaves the page before it is done.
Learning goals
After this chapter you can
- read the routes of the course API and start the API,
- load data in an action and show loading, error and empty states,
- send data as JSON and evaluate the answers of the API,
- abort a request with
AbortController, - set the address of the API and explain when CORS is needed.
The course API
Section titled “The course API”The small server of the earlier parts could only deliver the catalog. From this part on, the course API in the folder api of the course repo plays the association's server. It is a Node program without dependencies and runs in a terminal of its own.
cd apinpm startAfter that it answers at http://localhost:3000. Every path starts with /de or /en, which chooses the language of the data and of the messages. The answers are JSON, and an error comes as { "error": "…" }.
| Method and path | Who | Answer |
|---|---|---|
GET /items | everyone | all things |
GET /items/:id | everyone | one thing, 404 for an unknown number |
POST /session | everyone | signs in with { "number": "M-117" }, 401 for an unknown number |
GET /session | everyone | the signed-in member, 401 without a login |
DELETE /session | everyone | signs out |
GET /reservations | members | the own requests |
POST /reservations | members | a request, 201, 422 with a message per field, 409 for days already requested |
DELETE /reservations/:id | members | withdraws an own request |
POST /items/:id/return | team | records a return, 403 without a team account |
Like the small server, the API waits two seconds with ?delay=2000 and answers with that status for ?status=503. It keeps returns and requests in api/storage/state.json, and npm run reset starts over.
Loading in an action
Section titled “Loading in an action”An action of the store loads the data from the server. Next to the data, the store keeps the status of the request, and every page shows something for every status. fetch only rejects when no answer arrives at all, and the action checks a status like 503 itself, as in chapter 1.4.
App.vue
<script setup>import { ref } from 'vue'
import { useCatalogStore } from './catalog.js'
const catalog = useCatalogStore()const address = ref('/api/items')catalog.load(address.value)</script>
<template> <p> <label for="address">Address</label> <select id="address" v-model="address" @change="catalog.load(address)"> <option value="/api/items">normal</option> <option value="/api/items?delay=3000">slow</option> <option value="/api/items?status=503">with an error</option> </select> </p> <p v-if="catalog.status === 'loading'" role="status">Loading the catalog …</p> <div v-else-if="catalog.status === 'error'" role="alert"> <p>{{ catalog.error }}</p> <button type="button" @click="catalog.load(address)">Try again</button> </div> <ul v-else> <li v-for="thing in catalog.items" :key="thing.id">{{ thing.name }}</li> </ul></template>catalog.js
import { defineStore } from 'pinia'import { ref } from 'vue'
export const useCatalogStore = defineStore('catalog', () => { const items = ref([]) // idle, loading, error or ready, the page shows something for each of them const status = ref('idle') const error = ref('')
async function load(address = '/api/items') { status.value = 'loading' try { const response = await fetch(address) // fetch only fails when there is no answer, a status like 503 has to be checked if (!response.ok) throw new Error(`The course API answers with the status ${response.status}.`) items.value = await response.json() status.value = 'ready' } catch (problem) { error.value = problem.message status.value = 'error' } }
return { items, status, error, load }})main.js
// The stand-in for the course API comes first, before any component calls fetchimport './mock-api.js'
import { createPinia } from 'pinia'import { createApp } from 'vue'
import App from './App.vue'
createApp(App).use(createPinia()).mount('#app')mock-api.js
// A stand-in for the course API inside the preview of the playground, which has no server.// It answers fetch calls below /api/ like the course API and passes every other call on.import { things } from './things.js'
const realFetch = window.fetch.bind(window)const requests = []
function answer(status, body) { return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })}
// Waits like a slow network, an aborted signal ends the wait as it ends a real requestfunction wait(ms, signal) { return new Promise((resolve, reject) => { if (signal?.aborted) return reject(signal.reason) const timer = setTimeout(resolve, ms) signal?.addEventListener('abort', () => { clearTimeout(timer) reject(signal.reason) }) })}
window.fetch = async (input, options = {}) => { const url = new URL(input, 'http://localhost') if (!url.pathname.startsWith('/api/')) return realFetch(input, options) const path = url.pathname.slice('/api'.length) const method = options.method ?? 'GET' // One thing takes between 200 and 1200 milliseconds, everything else 500, ?delay sets the time const random = path.startsWith('/items/') ? 200 + Math.random() * 1000 : 500 await wait(Number(url.searchParams.get('delay') ?? random), options.signal) const status = Number(url.searchParams.get('status') ?? 200) if (status !== 200) return answer(status, { error: `The course API answers with the status ${status}.` })
if (method === 'GET' && path === '/items') return answer(200, things) const item = /^\/items\/(\d+)$/.exec(path) if (method === 'GET' && item !== null) { const thing = things.find((entry) => entry.id === Number(item[1])) return thing ? answer(200, thing) : answer(404, { error: 'This thing does not exist.' }) } if (method === 'GET' && path === '/reservations') return answer(200, requests) if (method === 'POST' && path === '/reservations') { // The stand-in acts as if M-117 were signed in const { itemId, from = '', to = '', note = '' } = JSON.parse(options.body) const fields = {} if (from === '') fields.from = 'Choose the first day.' if (to === '') fields.to = 'Choose the last day.' if (Object.keys(fields).length > 0) return answer(422, { error: 'The request contains errors.', fields }) if (requests.some((other) => other.itemId === itemId && other.from <= to && from <= other.to)) { return answer(409, { error: 'The thing is already requested for some of these days.' }) } const request = { id: `R-${requests.length + 1}`, itemId, member: 'M-117', from, to, note } requests.push(request) return answer(201, request) } return answer(404, { error: 'The course API does not know this address.' })}things.js
// A few things of the catalogexport const things = [ { id: 1, name: 'Drill', deposit: 20, availableFrom: null }, { id: 2, name: 'Tent for four', deposit: 50, availableFrom: '2026-11-12' }, { id: 5, name: 'Jigsaw', deposit: 20, availableFrom: null }, { id: 6, name: 'Projector', deposit: 80, availableFrom: null }, { id: 15, name: 'Circular saw', deposit: 40, availableFrom: null },]The playground has no server. That's why the file mock-api.js plays the course API. It intercepts calls of fetch below /api/ and answers like the API, with ?delay and ?status. In a project it goes away, and the real API answers.
Sending requests
Section titled “Sending requests”A request goes to the API with method: 'POST'. Its content sits as JSON in body, and the Content-Type header tells the API that it is JSON. The course API only accepts JSON and answers with 415 otherwise.
App.vue
<script setup>import { reactive, ref } from 'vue'
import { useRequestsStore } from './requests.js'
const requests = useRequestsStore()requests.load()
const form = reactive({ from: '', to: '', note: '' })const result = ref({ fields: {} })const sending = ref(false)
async function submit() { sending.value = true result.value = await requests.send({ itemId: 6, ...form }) sending.value = false}</script>
<template> <h1>Reserve the projector</h1> <form @submit.prevent="submit"> <label for="from">First day</label> <input id="from" v-model="form.from" type="date"> <span v-if="result.fields.from" class="error">{{ result.fields.from }}</span> <label for="to">Last day</label> <input id="to" v-model="form.to" type="date"> <span v-if="result.fields.to" class="error">{{ result.fields.to }}</span> <button :disabled="sending">{{ sending ? 'Sending …' : 'Send request' }}</button> </form> <p v-if="result.error" role="alert" class="error">{{ result.error }}</p> <p v-if="result.reservation" role="status">Request {{ result.reservation.id }} is sent.</p> <h2>My requests</h2> <p v-if="requests.list.length === 0">No requests yet.</p> <ul v-else> <li v-for="request in requests.list" :key="request.id">{{ request.id }}: {{ request.from }} to {{ request.to }}</li> </ul></template>
<style scoped>form { display: grid; gap: 0.25rem; max-width: 20rem;}.error { color: #a3341f;}</style>main.js
// The stand-in for the course API comes first, before any component calls fetchimport './mock-api.js'
import { createPinia } from 'pinia'import { createApp } from 'vue'
import App from './App.vue'
createApp(App).use(createPinia()).mount('#app')mock-api.js
// A stand-in for the course API inside the preview of the playground, which has no server.// It answers fetch calls below /api/ like the course API and passes every other call on.import { things } from './things.js'
const realFetch = window.fetch.bind(window)const requests = []
function answer(status, body) { return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })}
// Waits like a slow network, an aborted signal ends the wait as it ends a real requestfunction wait(ms, signal) { return new Promise((resolve, reject) => { if (signal?.aborted) return reject(signal.reason) const timer = setTimeout(resolve, ms) signal?.addEventListener('abort', () => { clearTimeout(timer) reject(signal.reason) }) })}
window.fetch = async (input, options = {}) => { const url = new URL(input, 'http://localhost') if (!url.pathname.startsWith('/api/')) return realFetch(input, options) const path = url.pathname.slice('/api'.length) const method = options.method ?? 'GET' // One thing takes between 200 and 1200 milliseconds, everything else 500, ?delay sets the time const random = path.startsWith('/items/') ? 200 + Math.random() * 1000 : 500 await wait(Number(url.searchParams.get('delay') ?? random), options.signal) const status = Number(url.searchParams.get('status') ?? 200) if (status !== 200) return answer(status, { error: `The course API answers with the status ${status}.` })
if (method === 'GET' && path === '/items') return answer(200, things) const item = /^\/items\/(\d+)$/.exec(path) if (method === 'GET' && item !== null) { const thing = things.find((entry) => entry.id === Number(item[1])) return thing ? answer(200, thing) : answer(404, { error: 'This thing does not exist.' }) } if (method === 'GET' && path === '/reservations') return answer(200, requests) if (method === 'POST' && path === '/reservations') { // The stand-in acts as if M-117 were signed in const { itemId, from = '', to = '', note = '' } = JSON.parse(options.body) const fields = {} if (from === '') fields.from = 'Choose the first day.' if (to === '') fields.to = 'Choose the last day.' if (Object.keys(fields).length > 0) return answer(422, { error: 'The request contains errors.', fields }) if (requests.some((other) => other.itemId === itemId && other.from <= to && from <= other.to)) { return answer(409, { error: 'The thing is already requested for some of these days.' }) } const request = { id: `R-${requests.length + 1}`, itemId, member: 'M-117', from, to, note } requests.push(request) return answer(201, request) } return answer(404, { error: 'The course API does not know this address.' })}requests.js
import { defineStore } from 'pinia'import { ref } from 'vue'
export const useRequestsStore = defineStore('requests', () => { const list = ref([])
async function load() { const response = await fetch('/api/reservations') if (response.ok) list.value = await response.json() }
// Sends a request as JSON and returns what the API said about it async function send(request) { const response = await fetch('/api/reservations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(request), }) const body = await response.json() if (!response.ok) return { error: body.error, fields: body.fields ?? {} } list.value.push(body) return { reservation: body, fields: {} } }
return { list, load, send }})things.js
// A few things of the catalogexport const things = [ { id: 1, name: 'Drill', deposit: 20, availableFrom: null }, { id: 2, name: 'Tent for four', deposit: 50, availableFrom: '2026-11-12' }, { id: 5, name: 'Jigsaw', deposit: 20, availableFrom: null }, { id: 6, name: 'Projector', deposit: 80, availableFrom: null }, { id: 15, name: 'Circular saw', deposit: 40, availableFrom: null },]The status of the answer tells how things turned out. 201 means created, 422 means that fields are wrong, and 409 reports a conflict, which for the course API means days that are already requested. The form already checks the rules of the association before sending, as it has since part 3. The API checks them once more anyway, because anyone can send a request without the app, and only the server knows about conflicts with other members' requests.
Signing in at the course API
Section titled “Signing in at the course API”The course API simulates the login with the member number and knows no password. After POST /session it sets a cookie, and the browser sends it along with every further request to the same address. That's how the API knows who sends a request. The store of the login doesn't see this cookie, it only keeps the member from the answer.
// The course API checks the member number and sets the session cookie. // Returns the message of the API for a failed login and an empty text when it worked. async function signIn(number) { const { ok, data } = await callApi('/session', { method: 'POST', body: { number } }) if (!ok) return data.error member.value = data return '' }Now the server protects the team area as well. The API refuses a return without a team account with 403, no matter what the guard in the browser shows. Chapter 7.4 explains why a cookie suits the login better than the browser's storage.
Aborting the previous request
Section titled “Aborting the previous request”Chapter 5.2 skipped outdated answers with a cleanup function. With an AbortController from chapter 1.4, the same cleanup function aborts the old request instead of only skipping its answer.
App.vue
<script setup>import { onWatcherCleanup, ref, watch } from 'vue'
import { things } from './things.js'
const index = ref(0)const thing = ref(null)
watch( index, async (now) => { const id = things[now].id const controller = new AbortController() // Runs before the next change of index, the old request is cancelled onWatcherCleanup(() => controller.abort()) try { const response = await fetch(`/api/items/${id}`, { signal: controller.signal }) thing.value = await response.json() console.log('Answer for', id) } catch (problem) { if (problem.name !== 'AbortError') throw problem console.log('Cancelled', id) } }, { immediate: true },)
function next() { index.value = (index.value + 1) % things.length}</script>
<template> <button type="button" @click="next">Next thing</button> <p>Chosen: {{ things[index].name }}</p> <h1>{{ thing?.name ?? '…' }}</h1></template>main.js
// The stand-in for the course API comes first, before any component calls fetchimport './mock-api.js'
import { createPinia } from 'pinia'import { createApp } from 'vue'
import App from './App.vue'
createApp(App).use(createPinia()).mount('#app')mock-api.js
// A stand-in for the course API inside the preview of the playground, which has no server.// It answers fetch calls below /api/ like the course API and passes every other call on.import { things } from './things.js'
const realFetch = window.fetch.bind(window)const requests = []
function answer(status, body) { return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })}
// Waits like a slow network, an aborted signal ends the wait as it ends a real requestfunction wait(ms, signal) { return new Promise((resolve, reject) => { if (signal?.aborted) return reject(signal.reason) const timer = setTimeout(resolve, ms) signal?.addEventListener('abort', () => { clearTimeout(timer) reject(signal.reason) }) })}
window.fetch = async (input, options = {}) => { const url = new URL(input, 'http://localhost') if (!url.pathname.startsWith('/api/')) return realFetch(input, options) const path = url.pathname.slice('/api'.length) const method = options.method ?? 'GET' // One thing takes between 200 and 1200 milliseconds, everything else 500, ?delay sets the time const random = path.startsWith('/items/') ? 200 + Math.random() * 1000 : 500 await wait(Number(url.searchParams.get('delay') ?? random), options.signal) const status = Number(url.searchParams.get('status') ?? 200) if (status !== 200) return answer(status, { error: `The course API answers with the status ${status}.` })
if (method === 'GET' && path === '/items') return answer(200, things) const item = /^\/items\/(\d+)$/.exec(path) if (method === 'GET' && item !== null) { const thing = things.find((entry) => entry.id === Number(item[1])) return thing ? answer(200, thing) : answer(404, { error: 'This thing does not exist.' }) } if (method === 'GET' && path === '/reservations') return answer(200, requests) if (method === 'POST' && path === '/reservations') { // The stand-in acts as if M-117 were signed in const { itemId, from = '', to = '', note = '' } = JSON.parse(options.body) const fields = {} if (from === '') fields.from = 'Choose the first day.' if (to === '') fields.to = 'Choose the last day.' if (Object.keys(fields).length > 0) return answer(422, { error: 'The request contains errors.', fields }) if (requests.some((other) => other.itemId === itemId && other.from <= to && from <= other.to)) { return answer(409, { error: 'The thing is already requested for some of these days.' }) } const request = { id: `R-${requests.length + 1}`, itemId, member: 'M-117', from, to, note } requests.push(request) return answer(201, request) } return answer(404, { error: 'The course API does not know this address.' })}things.js
// A few things of the catalogexport const things = [ { id: 1, name: 'Drill', deposit: 20, availableFrom: null }, { id: 2, name: 'Tent for four', deposit: 50, availableFrom: '2026-11-12' }, { id: 5, name: 'Jigsaw', deposit: 20, availableFrom: null }, { id: 6, name: 'Projector', deposit: 80, availableFrom: null }, { id: 15, name: 'Circular saw', deposit: 40, availableFrom: null },]When you click "Next thing" quickly several times, the console reports the cancelled requests, and only the last answer appears. A call of fetch that was aborted rejects with an AbortError, and the page skips it. As in chapter 1.4, AbortSignal.timeout aborts a request that takes too long.
The address of the API and CORS
Section titled “The address of the API and CORS”The projects of this part read the address of the API from VITE_API_URL and otherwise use /api/en. The Vite proxy from chapter 6.2 forwards everything below /api to the course API. For the browser, the app and the API share one address that way.
# Address of the course API. Copy this file to .env.local to change it on your machine.# Only variables that start with VITE_ reach the browser, never put a secret into one.VITE_API_URL=/api/enIf the app calls the API directly at http://localhost:3000/en, the request goes to another port and thus to another origin. According to MDN, the browser restricts such requests from scripts. It only hands out the answer when the server allows the origin of the page with the Access-Control-Allow-Origin header. For requests with JSON, the browser first asks with OPTIONS whether the method and the headers are allowed. A simple request still gets processed by the server, and the browser only withholds the answer. The course API allows the addresses of Vite on the ports 5173 and 4173. With cookies, it also takes credentials: 'include' in fetch and the server's permission, which is why the projects stick with the proxy.
In code
Section titled “In code”Work with the examples of this chapter. Read the code and write down what you expect before you change anything.
- In the first example, choose "slow" and right afterwards "with an error". Which state stays in the end?
- In the example with the request, send the same days twice. What does the stand-in for the API answer the second time?
- In the same example, remove the
Content-Typeheader. The stand-in doesn't notice, but what would the course API answer? - In the example on aborting, remove the line with
onWatcherCleanupand click quickly several times. Which thing ends up as the heading?
Exercises
Section titled “Exercises”The exercises build the catalog and the requests from the assignment. You'll find the project in the course repo in the folder exercises/07-state/api/en. The store of the login already talks to the course API. Copy the folder, install the packages and start the tests. The tests need no server, and for the app in the browser you start the course API in a second terminal.
npm installnpm testExercise Getting started
The store catalog still reads the catalog from a file. Load it in the action load from ${API}/items. The state status is loading, error or ready, and on an error error holds a message such as "The course API answers with the status 503.".
Solution
export const useCatalogStore = defineStore('catalog', () => { const list = ref([]) // loading, error or ready, the pages show something for each of them const status = ref('loading') const error = ref('')
async function load() { status.value = 'loading' try { const response = await fetch(`${API}/items`) // fetch only fails without an answer, a status such as 503 has to be checked if (!response.ok) throw new Error(`The course API answers with the status ${response.status}.`) list.value = await response.json() status.value = 'ready' } catch (problem) { error.value = problem instanceof TypeError ? 'The course API cannot be reached.' : problem.message status.value = 'error' } }Without an answer, fetch rejects with a TypeError, and for that the solution reports that the course API cannot be reached. App calls load on start, and the catalog page shows something for every status.
Exercise Going further
The action send in the store requests only keeps requests in the browser so far. Send them as JSON with POST to ${API}/reservations. On success, send returns { reservation, fields: {} }, and on a refusal { error, fields } with the message and the messages per field from the answer.
Solution
// Sends a request as JSON and returns what the course API said about it async function send(request) { const response = await fetch(`${API}/reservations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(request), }) const body = await response.json() if (!response.ok) return { error: body.error, fields: body.fields ?? {} } list.value.push(body) return { reservation: body, fields: {} } }The page of a thing already shows result.error and the messages per field. In the browser, a member can sign in as M-117 or M-204, and the store auth handles the login at the course API.
Exercise Challenge
So far the overview "My requests" only shows what was requested in the browser. Load it in an action load(signal) from ${API}/reservations, show loading, error and an empty list, and abort the loading with an AbortController when someone leaves the page before it is done.
Solution
// Loads the own requests. With the signal, the page cancels the request when it closes. async function load(signal) { status.value = 'loading' try { const response = await fetch(`${API}/reservations`, { signal }) const body = await response.json() if (!response.ok) throw new Error(body.error) list.value = body status.value = 'ready' } catch (problem) { if (problem.name === 'AbortError') return error.value = problem.message status.value = 'error' } }// Whoever leaves the page before the answer arrives cancels the requestconst controller = new AbortController()onMounted(() => requests.load(controller.signal))onUnmounted(() => controller.abort())An abort is no error, so the action sets no status for it. Without a login, the API answers with 401, and the page shows its message.
Common mistakes
Section titled “Common mistakes”| Mistake | What helps |
|---|---|
| A status like 500 shows up as a success. | fetch only rejects without an answer. Check response.ok. |
| The course API answers with 415. | The header Content-Type: application/json is missing, or the content isn't JSON. |
| The console reports that CORS blocked a request. | The app calls the API at another origin. Use the proxy, or allow the origin on the server. |
| After leaving a page, a late answer still sets some state. | Abort the request with an AbortController when the page goes away. |
| An AI assistant relies on the checks in the form alone. | The server checks every request once more, and the app shows its messages. |
Self-check
Section titled “Self-check”Self-check
- When does
fetchreject, and when does the action have to detect an error itself? - What do the answers 201, 401, 409 and 422 of the course API mean?
- Why does the course API check the rules even though the form already does?
- Why doesn't the app need CORS with the Vite proxy?
Answers
fetchonly rejects when no answer arrives or the request gets aborted. The action checks every status itself withresponse.ok.- 201 means created, 401 a missing login, 409 a conflict with days already requested and 422 fields that are wrong.
- Anyone can send requests without the app, and only the server knows about conflicts with other people's requests.
- For the browser, all requests go to the address of the app. Only Vite forwards them to the API, and the browser never sees another origin.
Assignment
The catalog, the requests and the returns all go through the course API. After a reload, though, the login is gone from the store, and a member who marks favorites in a second tab doesn't see them in the first one. Martina Hostettler also reports that half-filled requests get lost at the counter as soon as someone confirms the question on leaving to check the catalog quickly. The next chapter settles what the app keeps where in the browser.
Text and figures are licensed under CC BY-SA 4.0, the code under the MIT License and the course data under CC0 1.0. The figures use the font Kalam under the SIL Open Font License 1.1. The sources are on GitHub.