Zum Inhalt springen

Daten von einer API

Auftrag

Martina Hostettler möchte, dass die Anfragen beim Verein ankommen.

Als Freiwillige oder Freiwilliger am Tresen möchte ich die Anfragen der Mitglieder auf dem Server des Vereins sehen, damit wir die Dinge bereitlegen können.

Der Freiwillige ergänzt, dass auch der Katalog vom Server kommen soll, damit eine Rückgabe am Tresen bei allen ankommt. Die Akzeptanzkriterien lauten:

  1. Der Katalog kommt von der Kurs-API. Die App zeigt, dass er lädt, und meldet Fehler verständlich.
  2. Eine Anfrage geht an die Kurs-API, und die App zeigt deren Antwort, auch eine Ablehnung.
  3. Die Übersicht «Meine Anfragen» lädt von der Kurs-API und bricht das Laden ab, wenn jemand die Seite vorher verlässt.

Lernziele

Nach diesem Kapitel kannst du

  • die Routen der Kurs-API lesen und die API starten,
  • Daten in einer Action laden und Lade-, Fehler- und leere Zustände zeigen,
  • Daten als JSON senden und die Antworten der API auswerten,
  • eine Anfrage mit AbortController abbrechen,
  • die Adresse der API einstellen und erklären, wann CORS nötig ist.

Der kleine Server aus den früheren Teilen konnte nur den Katalog liefern. Ab diesem Teil spielt die Kurs-API im Ordner api des Kursrepos den Server des Vereins. Sie ist ein Programm für Node ohne Abhängigkeiten und läuft in einem eigenen Terminal.

Terminal-Fenster
cd api
npm start

Danach antwortet sie auf http://localhost:3000. Jeder Pfad beginnt mit /de oder /en, das wählt die Sprache der Daten und der Meldungen. Die Antworten sind JSON, ein Fehler kommt als { "error": "…" }.

Methode und PfadWerAntwort
GET /itemsallealle Dinge
GET /items/:idalleein Ding, 404 für eine unbekannte Nummer
POST /sessionallemeldet mit { "number": "M-117" } an, 401 für eine unbekannte Nummer
GET /sessionalledas angemeldete Mitglied, 401 ohne Anmeldung
DELETE /sessionallemeldet ab
GET /reservationsMitgliederdie eigenen Anfragen
POST /reservationsMitgliedereine Anfrage, 201, 422 mit einer Meldung pro Feld, 409 für schon angefragte Tage
DELETE /reservations/:idMitgliederzieht eine eigene Anfrage zurück
POST /items/:id/returnTeamerfasst eine Rückgabe, 403 ohne Konto des Teams

Wie der kleine Server wartet die API mit ?delay=2000 zwei Sekunden und antwortet mit ?status=503 mit diesem Status. Rückgaben und Anfragen hält sie in api/storage/state.json fest, npm run reset setzt sie zurück.

Daten vom Server lädt eine Action des Stores. Der Store hält neben den Daten den Status der Anfrage, und jede Seite zeigt für jeden Status etwas. fetch lehnt nur ab, wenn gar keine Antwort kommt, einen Status wie 503 prüft die Action selbst, wie in Kapitel 1.4.

App.vue

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">Adresse</label>
<select id="address" v-model="address" @change="catalog.load(address)">
<option value="/api/items">normal</option>
<option value="/api/items?delay=3000">langsam</option>
<option value="/api/items?status=503">mit Fehler</option>
</select>
</p>
<p v-if="catalog.status === 'loading'" role="status">Der Katalog wird geladen …</p>
<div v-else-if="catalog.status === 'error'" role="alert">
<p>{{ catalog.error }}</p>
<button type="button" @click="catalog.load(address)">Nochmals versuchen</button>
</div>
<ul v-else>
<li v-for="thing in catalog.items" :key="thing.id">{{ thing.name }}</li>
</ul>
</template>

catalog.js

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(`Die Kurs-API antwortet mit dem 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

main.js
// The stand-in for the course API comes first, before any component calls fetch
import './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

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 request
function 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: `Die Kurs-API antwortet mit dem 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: 'Dieses Ding gibt es nicht.' })
}
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 = 'Wähl den ersten Tag.'
if (to === '') fields.to = 'Wähl den letzten Tag.'
if (Object.keys(fields).length > 0) return answer(422, { error: 'Die Anfrage enthält Fehler.', fields })
if (requests.some((other) => other.itemId === itemId && other.from <= to && from <= other.to)) {
return answer(409, { error: 'Das Ding ist an einigen dieser Tage schon angefragt.' })
}
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: 'Diese Adresse kennt die Kurs-API nicht.' })
}

things.js

things.js
// A few things of the catalog
export const things = [
{ id: 1, name: 'Bohrmaschine', deposit: 20, availableFrom: null },
{ id: 2, name: 'Zelt für vier Personen', deposit: 50, availableFrom: '2026-11-12' },
{ id: 5, name: 'Stichsäge', deposit: 20, availableFrom: null },
{ id: 6, name: 'Beamer', deposit: 80, availableFrom: null },
{ id: 15, name: 'Kreissäge', deposit: 40, availableFrom: null },
]

Der Playground hat keinen Server. Die Datei mock-api.js spielt darum die Kurs-API nach, sie fängt Aufrufe von fetch unter /api/ ab und antwortet wie die API, mit ?delay und ?status. In einem Projekt fällt sie weg, dort antwortet die echte API.

Eine Anfrage geht mit method: 'POST' an die API. Der Inhalt steht als JSON in body, und der Kopf Content-Type sagt der API, dass es JSON ist. Die Kurs-API nimmt nur JSON an und antwortet sonst mit 415.

App.vue

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>Beamer reservieren</h1>
<form @submit.prevent="submit">
<label for="from">Erster Tag</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">Letzter Tag</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 ? 'Wird gesendet …' : 'Anfrage senden' }}</button>
</form>
<p v-if="result.error" role="alert" class="error">{{ result.error }}</p>
<p v-if="result.reservation" role="status">Anfrage {{ result.reservation.id }} ist gesendet.</p>
<h2>Meine Anfragen</h2>
<p v-if="requests.list.length === 0">Noch keine Anfragen.</p>
<ul v-else>
<li v-for="request in requests.list" :key="request.id">{{ request.id }}: {{ request.from }} bis {{ request.to }}</li>
</ul>
</template>
<style scoped>
form {
display: grid;
gap: 0.25rem;
max-width: 20rem;
}
.error {
color: #a3341f;
}
</style>

main.js

main.js
// The stand-in for the course API comes first, before any component calls fetch
import './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

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 request
function 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: `Die Kurs-API antwortet mit dem 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: 'Dieses Ding gibt es nicht.' })
}
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 = 'Wähl den ersten Tag.'
if (to === '') fields.to = 'Wähl den letzten Tag.'
if (Object.keys(fields).length > 0) return answer(422, { error: 'Die Anfrage enthält Fehler.', fields })
if (requests.some((other) => other.itemId === itemId && other.from <= to && from <= other.to)) {
return answer(409, { error: 'Das Ding ist an einigen dieser Tage schon angefragt.' })
}
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: 'Diese Adresse kennt die Kurs-API nicht.' })
}

requests.js

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

things.js
// A few things of the catalog
export const things = [
{ id: 1, name: 'Bohrmaschine', deposit: 20, availableFrom: null },
{ id: 2, name: 'Zelt für vier Personen', deposit: 50, availableFrom: '2026-11-12' },
{ id: 5, name: 'Stichsäge', deposit: 20, availableFrom: null },
{ id: 6, name: 'Beamer', deposit: 80, availableFrom: null },
{ id: 15, name: 'Kreissäge', deposit: 40, availableFrom: null },
]

Die Antwort sagt mit ihrem Status, wie es ausging. 201 heisst angelegt, 422 heisst, dass Felder nicht stimmen, und 409 meldet einen Konflikt, im Fall der Kurs-API Tage, die schon angefragt sind. Das Formular prüft die Regeln des Vereins schon vor dem Senden, wie seit Teil 3. Die API prüft sie trotzdem noch einmal, denn jeder kann eine Anfrage auch ohne die App senden, und Konflikte mit Anfragen anderer Mitglieder kennt nur der Server.

Die Kurs-API simuliert die Anmeldung mit der Mitgliedernummer, ein Passwort kennt sie nicht. Nach POST /session setzt sie ein Cookie, und der Browser schickt es bei jeder weiteren Anfrage an dieselbe Adresse mit. So weiss die API, wer eine Anfrage sendet. Der Store der Anmeldung sieht dieses Cookie nicht, er merkt sich nur das Mitglied aus der Antwort.

auth.js
// 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 ''
}

Den Bereich des Teams schützt jetzt auch der Server. Eine Rückgabe ohne Konto des Teams lehnt die API mit 403 ab, egal was der Guard im Browser zeigt. Kapitel 7.4 erklärt, warum das Cookie für die Anmeldung besser passt als der Speicher des Browsers.

Kapitel 5.2 hat veraltete Antworten mit einer Aufräumfunktion übergangen. Mit einem AbortController aus Kapitel 1.4 bricht dieselbe Aufräumfunktion die alte Anfrage ab, statt ihre Antwort nur zu übergehen.

App.vue

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('Antwort für', id)
} catch (problem) {
if (problem.name !== 'AbortError') throw problem
console.log('Abgebrochen', id)
}
},
{ immediate: true },
)
function next() {
index.value = (index.value + 1) % things.length
}
</script>
<template>
<button type="button" @click="next">Nächstes Ding</button>
<p>Gewählt: {{ things[index].name }}</p>
<h1>{{ thing?.name ?? '…' }}</h1>
</template>

main.js

main.js
// The stand-in for the course API comes first, before any component calls fetch
import './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

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 request
function 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: `Die Kurs-API antwortet mit dem 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: 'Dieses Ding gibt es nicht.' })
}
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 = 'Wähl den ersten Tag.'
if (to === '') fields.to = 'Wähl den letzten Tag.'
if (Object.keys(fields).length > 0) return answer(422, { error: 'Die Anfrage enthält Fehler.', fields })
if (requests.some((other) => other.itemId === itemId && other.from <= to && from <= other.to)) {
return answer(409, { error: 'Das Ding ist an einigen dieser Tage schon angefragt.' })
}
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: 'Diese Adresse kennt die Kurs-API nicht.' })
}

things.js

things.js
// A few things of the catalog
export const things = [
{ id: 1, name: 'Bohrmaschine', deposit: 20, availableFrom: null },
{ id: 2, name: 'Zelt für vier Personen', deposit: 50, availableFrom: '2026-11-12' },
{ id: 5, name: 'Stichsäge', deposit: 20, availableFrom: null },
{ id: 6, name: 'Beamer', deposit: 80, availableFrom: null },
{ id: 15, name: 'Kreissäge', deposit: 40, availableFrom: null },
]

Klickst du schnell mehrmals auf «Nächstes Ding», meldet die Konsole die abgebrochenen Anfragen, und nur die letzte Antwort erscheint. Ein abgebrochener Aufruf von fetch lehnt mit einem AbortError ab, und die Seite übergeht ihn. Wie in Kapitel 1.4 bricht AbortSignal.timeout eine Anfrage ab, die zu lange dauert.

Die Projekte dieses Teils lesen die Adresse der API aus VITE_API_URL und nehmen sonst /api/de. Der Proxy von Vite aus Kapitel 6.2 leitet alles unter /api an die Kurs-API weiter. Für den Browser liegen App und API damit auf derselben Adresse.

.env.example
# 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/de
Der Weg einer Anfrage Eine Folge von oben nach unten. Eine Komponente ruft eine Action des Stores auf, der Store schickt fetch an /api/de/items. Vite auf Port 5173 nimmt die Anfrage an und leitet alles unter /api an die Kurs-API auf Port 3000 weiter, die GET /de/items beantwortet und Rückgaben und Anfragen in storage/state.json festhält. Die Antwort nimmt denselben Weg zurück. Rechts führt ein gestrichelter Weg vom Store direkt an http://localhost:3000. Dieser Weg geht an eine andere Adresse und braucht darum CORS. Komponente ruft eine Action auf Store fetch('/api/de/items') Vite auf Port 5173 Proxy, leitet /api weiter Kurs-API auf Port 3000 GET /de/items storage/state.json Rückgaben und Anfragen direkt an localhost:3000 nur mit CORS

Ruft die App die API direkt unter http://localhost:3000/de auf, geht die Anfrage an einen anderen Port und damit an eine andere Origin. Laut MDN schränkt der Browser solche Anfragen aus Skripten ein. Er gibt die Antwort nur heraus, wenn der Server die Origin der Seite mit dem Kopf Access-Control-Allow-Origin erlaubt. Für Anfragen mit JSON fragt der Browser vorher mit OPTIONS nach, ob Methode und Kopfzeilen erlaubt sind. Bei einer einfachen Anfrage bearbeitet der Server sie trotzdem, der Browser hält nur die Antwort zurück. Die Kurs-API erlaubt die Adressen von Vite auf den Ports 5173 und 4173. Mit Cookies braucht es zusätzlich credentials: 'include' in fetch und die Erlaubnis des Servers, darum bleiben die Projekte beim Proxy.

Arbeite mit den Beispielen dieses Kapitels. Lies den Code und schreib auf, was du erwartest, bevor du etwas änderst.

  1. Wähl im ersten Beispiel «langsam» und gleich danach «mit Fehler». Welcher Zustand bleibt am Ende stehen?
  2. Sende im Beispiel mit der Anfrage zweimal dieselben Tage. Was antwortet die Attrappe der API beim zweiten Mal?
  3. Nimm im selben Beispiel den Kopf Content-Type weg. Die Attrappe merkt es nicht, was würde die Kurs-API antworten?
  4. Nimm im Beispiel zum Abbrechen die Zeile mit onWatcherCleanup weg und klick schnell mehrmals. Welches Ding steht am Ende als Überschrift?

Die Aufgaben bauen den Katalog und die Anfragen aus dem Auftrag. Du findest das Projekt im Kursrepo im Ordner exercises/07-state/api/de. Der Store der Anmeldung spricht schon mit der Kurs-API. Kopiere den Ordner, installiere die Pakete und starte die Tests. Die Tests brauchen keinen Server, für die App im Browser startest du die Kurs-API in einem zweiten Terminal.

Terminal-Fenster
npm install
npm test

Aufgabe Einstieg

Der Store catalog liest den Katalog noch aus einer Datei. Lade ihn in der Action load von ${API}/items. Der State status ist loading, error oder ready, und error hält bei einem Fehler eine Meldung wie «Die Kurs-API antwortet mit dem Status 503.».

Lösung
catalog.js
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(`Die Kurs-API antwortet mit dem Status ${response.status}.`)
list.value = await response.json()
status.value = 'ready'
} catch (problem) {
error.value = problem instanceof TypeError ? 'Die Kurs-API ist nicht erreichbar.' : problem.message
status.value = 'error'
}
}

Ohne Antwort lehnt fetch mit einem TypeError ab, dafür meldet die Lösung, dass die Kurs-API nicht erreichbar ist. App ruft load beim Start auf, und die Seite des Katalogs zeigt für jeden Status etwas.

Aufgabe Vertiefung

Die Action send im Store requests legt Anfragen bisher nur im Browser ab. Schick sie mit POST als JSON an ${API}/reservations. Bei Erfolg gibt send { reservation, fields: {} } zurück, bei einer Ablehnung { error, fields } mit der Meldung und den Meldungen pro Feld aus der Antwort.

Lösung
requests.js
// 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: {} }
}

Die Seite eines Dings zeigt result.error und die Meldungen pro Feld schon an. Ein Mitglied kann sich im Browser mit M-117 oder M-204 anmelden, die Anmeldung erledigt der Store auth bei der Kurs-API.

Aufgabe Knobelei

Die Übersicht «Meine Anfragen» zeigt bisher nur, was im Browser angefragt wurde. Lade sie in einer Action load(signal) von ${API}/reservations, zeige Laden, Fehler und eine leere Liste, und brich das Laden mit einem AbortController ab, wenn jemand die Seite vorher verlässt.

Lösung
requests.js
// 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'
}
}
RequestsView.vue
// Whoever leaves the page before the answer arrives cancels the request
const controller = new AbortController()
onMounted(() => requests.load(controller.signal))
onUnmounted(() => controller.abort())

Ein Abbruch ist kein Fehler, darum setzt die Action dafür keinen Status. Ohne Anmeldung antwortet die API mit 401, und die Seite zeigt deren Meldung.

FehlerWas hilft
Ein Status wie 500 erscheint als Erfolg.fetch lehnt nur ohne Antwort ab. response.ok prüfen.
Die Kurs-API antwortet mit 415.Der Kopf Content-Type: application/json fehlt, oder der Inhalt ist kein JSON.
Die Konsole meldet, dass CORS eine Anfrage blockiert.Die App ruft die API auf einer anderen Origin auf. Den Proxy verwenden oder die Origin auf dem Server erlauben.
Nach dem Verlassen einer Seite setzt eine späte Antwort noch einen Zustand.Die Anfrage mit einem AbortController abbrechen, wenn die Seite verschwindet.
Ein KI-Assistent verlässt sich nur auf die Prüfung im Formular.Der Server prüft jede Anfrage noch einmal, die App zeigt seine Meldungen.

Selbstcheck

  1. Wann lehnt fetch ab, und wann muss die Action selbst einen Fehler erkennen?
  2. Was bedeuten die Antworten 201, 401, 409 und 422 der Kurs-API?
  3. Warum prüft die Kurs-API die Regeln, obwohl das Formular sie schon prüft?
  4. Warum braucht die App mit dem Proxy von Vite kein CORS?
Antworten
  1. fetch lehnt nur ab, wenn keine Antwort kommt oder die Anfrage abgebrochen wird. Jeden Status prüft die Action mit response.ok selbst.
  2. 201 heisst angelegt, 401 fehlende Anmeldung, 409 ein Konflikt mit schon angefragten Tagen und 422 Felder, die nicht stimmen.
  3. Jeder kann Anfragen ohne die App senden, und Konflikte mit Anfragen anderer kennt nur der Server.
  4. Für den Browser gehen alle Anfragen an die Adresse der App. Erst Vite leitet sie an die API weiter, und der Browser sieht keine andere Origin.

Auftrag

Katalog, Anfragen und Rückgaben laufen über die Kurs-API. Nach dem Neuladen ist die Anmeldung im Store aber weg, und ein Mitglied, das im zweiten Tab Favoriten markiert, sieht sie im ersten nicht. Martina Hostettler meldet ausserdem, dass am Tresen halb ausgefüllte Anfragen verloren gehen, sobald jemand die Rückfrage beim Verlassen bestätigt, um kurz im Katalog nachzuschauen. Das nächste Kapitel klärt, was die App wo im Browser speichert.

Texte und Abbildungen stehen unter CC BY-SA 4.0, der Code unter der MIT-Lizenz und die Daten des Kurses unter CC0 1.0. Die Schrift der Abbildungen ist Kalam unter der SIL Open Font License 1.1. Die Quellen liegen auf GitHub.