083: REST and API Integration
Learning outcomes
By the end of this lesson, you should be able to:
- describe APIs in terms of resources, representations, endpoints, requests, and responses;
- choose common HTTP methods according to their standardized semantics;
- distinguish collection paths, item paths, path parameters, and query parameters;
- send and receive JSON with appropriate media-type headers;
- interpret important status codes without assuming every successful response is
200; - build a small CRUD-style frontend with safe rendering and an honest mock fallback when the API cannot be reached.
Retrieval warm-up
Before reading, try to answer these from memory:
- Why must Fetch code check
response.ok? - What is the difference between
Content-TypeandAccept? - How can an obsolete Fetch request be canceled?
Vocabulary
These terms are easy to blur together, so use the definitions precisely:
- API: A defined contract through which software components communicate. — Source: MDN: Glossary — API
- Resource: The target of a request, identified by a URI, whose state is transferred through representations. — Source: RFC 9110 §3.1 Resources
- Representation: Bytes plus metadata that describe a resource's current or intended state, such as JSON. — Source: RFC 9110 §3.2 Representations
- Endpoint: A usable method-and-URL combination exposed by an API. The URL by itself is not the complete endpoint because the method is part of the contract. — Source: RFC 9110 §9 Methods
- REST: An architectural style defined by constraints over resources and representations. It does not mean simply "a JSON API over HTTP." — Source: RFC 9110 §2 Architecture
- Collection: A resource that groups multiple items and is addressable through one shared path, such as
/posts. — Source: RFC 9110 §3.1 Resources - Path parameter: A variable path segment that identifies one item, such as
/posts/42. — Source: RFC 3986 §3.3 Path - Query parameter: A query-component pair used to filter or shape a response, such as
?userId=3. — Source: RFC 3986 §3.4 Query - CRUD: Create, Read, Update, Delete. This is an application-level model, not a set of HTTP semantics by itself. — Source: MDN: Glossary — CRUD
- Safe method: A method whose semantics are intended to be read-only, such as GET or HEAD. No state change is expected from the requested operation. — Source: RFC 9110 §9.2.1 Safe
- Idempotent method: A method for which repeating an identical request has the same intended effect as making it once, such as PUT or DELETE. — Source: RFC 9110 §9.2.2 Idempotent
- Resource representation (official): "A representation is a sequence of bytes plus metadata describing the current or intended state of a resource." — Source: RFC 9110: Representations
- Safe method (official): "A method is safe if its semantics are read-only; it does not change server state." — Source: RFC 9110: Safe Methods
- Idempotent method (official): "A method is idempotent if multiple identical requests have the same effect as a single request." — Source: RFC 9110: Idempotent Methods
Mental model: nouns in URLs, intent in methods
The useful distinction is between identifying a target and describing what you want done with it. RFC 9110 treats those as separate concerns: the URL identifies the target, while the method communicates the request's intent.
GET /posts retrieve a representation of the collection
GET /posts/7 retrieve post 7
POST /posts ask the collection to process a new post submission
PUT /posts/7 create or replace the complete state at known URI /posts/7
PATCH /posts/7 apply partial modifications (defined by RFC 5789)
DELETE /posts/7 remove the association/current representation for post 7
This is why action-heavy paths such as /getPosts are usually a poor first choice when standard method semantics already express the action. Real APIs can model actions as resources, and REST does not mandate one naming convention. Still, consistent resource-oriented paths make requests easier to read, document, and debug.
There is a subtle point about DELETE. Its HTTP definition is more precise than "erase the database row forever": the request asks the server to remove the association between the target resource and its current functionality. Whether the server deletes storage, marks a record inactive, or performs another implementation-specific operation is a server concern.
For a practical self-check, represent a request as four cards: method, target URI, headers, and optional content. Represent a response as status, headers, and optional content. Change one card at a time and explain how the meaning changes. This exposes two common misunderstandings: a URL alone is not the whole endpoint, and a JSON body is a representation of resource state, not the resource itself.
Path versus query
/users/3/posts/12 identifies a particular nested item
/posts?userId=3&limit=10 selects or shapes a collection representation
Path parameters usually answer "which resource?" Query parameters more often answer "which members or representation should be returned?" This is a convention rather than a universal law, but it is a useful way to read an API.
Query syntax is part of the URI, not private request storage. It can appear in browser history, logs, analytics, and caches. Never put passwords or access tokens in a query string.
Methods and status codes
Use the method whose standardized semantics match the operation rather than using POST as a generic escape hatch:
| Method | Typical API use | Safe | Idempotent |
|---|---|---|---|
| GET | retrieve | yes | yes |
| POST | submit/create under server-selected URI | no | no |
| PUT | create/replace at target URI | no | yes |
| PATCH | partial modification | no | not guaranteed |
| DELETE | delete target association | no | yes |
Idempotent does not mean that every repeated response is byte-for-byte identical, nor does it prevent logging or other incidental effects. It describes the intended server effect of repeating an identical request. That property is useful when deciding whether a retry is reasonable, but network uncertainty and application behavior still require care.
These response codes are especially useful when reading Network-panel output:
200 OK: The operation succeeded; any response content depends on the method and endpoint contract.201 Created: One or more resources were created; the server will usually includeLocationfor the primary new resource.202 Accepted: The server accepted the request for processing. This is not proof that processing eventually succeeded.204 No Content: The operation succeeded and the response has no content. Do not callresponse.json()on an empty 204 body.205 Reset Content: The operation succeeded and asks the client to reset its document view; it has no response content.400 Bad Request: The request is malformed or invalid at a broad level.401 Unauthorized: Authentication is required or invalid. The standardized name is historical; in practice it generally means the request is unauthenticated.403 Forbidden: The server understood the request but refuses authorization.404 Not Found: The target was not found, or the server is intentionally hiding its existence.405 Method Not Allowed: The method is known but is not supported for this target.409 Conflict: The request conflicts with the resource's current state.415 Unsupported Media Type: The request representation uses a format the server does not support.422 Unprocessable Content: The syntax was understood, but the instructions are semantically invalid.429 Too Many Requests: A rate limit was exceeded.500 Internal Server Errorand503 Service Unavailable: A server-side failure occurred; 503 commonly indicates a temporary condition.
Optional comparison: inspect a posts API
This read-only comparison uses JSONPlaceholder and falls back to local sample data. It is useful for inspecting request construction and UI states, but it is not the required CRUD exercise. That exercise uses the deterministic local repository below.
<form id="filter-form">
<label>
User ID
<input id="user-id" type="number" min="1" max="10" value="1" required>
</label>
<button>Load posts</button>
</form>
<p id="status" aria-live="polite"></p>
<ul id="posts"></ul>
<script>
const form = document.querySelector("#filter-form");
const userIdInput = document.querySelector("#user-id");
const status = document.querySelector("#status");
const postList = document.querySelector("#posts");
const samplePosts = [
{ id: "sample-1", userId: 1, title: "Local REST practice" },
];
function renderPosts(posts) {
postList.replaceChildren();
for (const post of posts) {
const item = document.createElement("li");
item.textContent = `#${post.id}: ${post.title}`;
postList.append(item);
}
}
function isJsonMediaType(value) {
if (typeof value !== "string") return false;
const mediaType = value.split(";", 1)[0].trim().toLowerCase();
return mediaType === "application/json";
}
async function getJson(url) {
const response = await fetch(url, {
headers: { Accept: "application/json" },
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const contentType = response.headers.get("content-type");
if (!isJsonMediaType(contentType)) {
throw new TypeError("Expected application/json");
}
return response.json();
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
status.textContent = "Loading...";
const url = new URL("https://jsonplaceholder.typicode.com/posts");
url.search = new URLSearchParams({ userId: userIdInput.value });
try {
const posts = await getJson(url);
if (!Array.isArray(posts)) throw new TypeError("Expected an array");
renderPosts(posts);
status.textContent = posts.length ? `${posts.length} posts` : "No posts found";
} catch (error) {
console.error("GET /posts failed", error);
renderPosts(samplePosts);
status.textContent = "API unavailable; showing sample data";
}
});
</script>
Step-by-step explanation
/postsidentifies a collection resource.userId=1is a query parameter that filters that collection.URLSearchParamsserializes it safely instead of relying on hand-built query strings.- GET asks for a representation and has no request body.
Accept: application/jsonexpresses the preferred response format. It does not force the server to comply, so this client uses the082isJsonMediaTypehelper to require exactapplication/jsonbefore parameters, compared case-insensitively.- HTTP failures and response-shape failures are made explicit before anything is rendered.
- Remote strings are inserted with
textContent, notinnerHTML. That keeps the rendering path safe for untrusted content. - The fallback is labeled clearly instead of being presented as current data from the server.
Expected output
When the network is available, the page reports the API's posts for the selected user. When it is offline, one sample post appears. Public API data and availability can change, so evaluate the request construction and the UI states rather than depending on an exact title string.
Intermediate example: CRUD request functions
JSONPlaceholder simulates mutations but does not persist them. The following functions are therefore examples of request contracts, not a durable create-update-delete workflow:
const baseUrl = "https://jsonplaceholder.typicode.com/posts";
function isJsonMediaType(value, allowStructuredSuffix = false) {
if (typeof value !== "string") return false;
const mediaType = value.split(";", 1)[0].trim().toLowerCase();
const subtype = mediaType.startsWith("application/")
? mediaType.slice("application/".length)
: "";
return mediaType === "application/json" ||
(allowStructuredSuffix &&
subtype.length > "+json".length &&
subtype.endsWith("+json"));
}
async function requestApi(
url,
{ responseType = "json", allowStructuredJson = false, ...options } = {},
) {
const method = (options.method || "GET").toUpperCase();
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
if (method === "HEAD" || response.status === 204 || response.status === 205) {
return null;
}
if (responseType === "none") return null;
if (responseType !== "json") {
throw new TypeError(`Unsupported response type: ${responseType}`);
}
const contentType = response.headers.get("content-type");
if (!isJsonMediaType(contentType, allowStructuredJson)) {
throw new TypeError("Expected JSON");
}
return response.json();
}
function createPost(post) {
return requestApi(baseUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(post),
});
}
function replacePost(id, post) {
return requestApi(`${baseUrl}/${encodeURIComponent(id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(post),
});
}
function updatePost(id, changes) {
return requestApi(`${baseUrl}/${encodeURIComponent(id)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(changes),
});
}
function deletePost(id) {
return requestApi(`${baseUrl}/${encodeURIComponent(id)}`, {
method: "DELETE",
responseType: "none",
});
}
Use the functions deliberately rather than assuming the simulation behaves like a persistent service:
async function demoCrud() {
const created = await createPost({
title: "HTTP semantics",
body: "Practice resource operations",
userId: 1,
});
console.log("Created representation:", created);
// JSONPlaceholder does not persist the created ID, so mutate a known fixture.
const updated = await updatePost(1, { title: "Updated title" });
console.log("Updated representation:", updated);
await deletePost(1);
console.log("Delete accepted by the simulation");
}
demoCrud().catch((error) => console.error(error.message));
A production create commonly returns 201 and Location, but an API is not required to echo a representation in every successful response. requestApi combines general protocol rules with an endpoint-specific contract: HEAD, 204, and 205 never parse a body, while an endpoint declared with responseType: "none" also returns null. Other endpoints explicitly default to JSON. Exact application/json is accepted case-insensitively after removing anything following the semicolon; an endpoint must set allowStructuredJson: true to accept application/*+json as well. The three JSONPlaceholder mutations are independent simulations, not one persisted lifecycle.
PUT normally sends a complete replacement known to the client, while PATCH sends changes in a patch media format agreed with the API. application/json objects that look like merge patches are common demonstrations, but they are not automatically standardized JSON Merge Patch. That format is application/merge-patch+json in RFC 7396.
Required local CRUD repository
When a public service is unavailable, or when the exercise depends on persistence behavior, use a local model instead:
function createRepository(initialPosts = []) {
let posts = structuredClone(initialPosts);
let nextId = Math.max(0, ...posts.map((post) => post.id)) + 1;
return {
list(userId) {
return Promise.resolve(
posts.filter((post) => userId === undefined || post.userId === userId),
);
},
create(input) {
const post = { ...input, id: nextId++ };
posts.push(post);
return Promise.resolve(structuredClone(post));
},
remove(id) {
const before = posts.length;
posts = posts.filter((post) => post.id !== id);
return Promise.resolve(posts.length < before);
},
};
}
const repository = createRepository([{ id: 1, userId: 1, title: "Sample" }]);
repository.create({ userId: 1, title: "New" }).then(console.log);
The expected created value is { userId: 1, title: "New", id: 2 }. This Promise-shaped interface is the required deterministic exercise. It stands in for network calls without pretending to implement HTTP status codes or caching semantics; those protocol concerns are covered separately above.
Common mistakes and debugging
- Calling every JSON API REST: REST includes architectural constraints beyond resource-looking URLs.
- Putting verbs in every URL: First ask whether the HTTP method already expresses the intent.
- Using POST for all operations: This throws away standardized safety and idempotency information.
- Treating PUT as a partial update: Standard PUT semantics replace the target state; use the API's defined PATCH format for partial changes.
- Returning
200for everything: Status codes communicate machine-readable outcomes to clients and intermediaries. - Parsing no-body responses as JSON: HEAD, 204, and 205 have no response content; an endpoint may also contractually return no content for another success status.
- Matching
Content-Typeby substring: Remove parameters such as the part after the semicolon and compare the complete media type case-insensitively. Allowapplication/*+jsononly when the endpoint contract says to. - Confusing 401 and 403: 401 generally requires authentication; 403 refuses the authenticated or otherwise understood request.
- Sending an object directly as a Fetch body: Serialize JSON and set its content type.
- Trusting client validation: The server must authenticate, authorize, validate, and enforce limits independently.
When debugging, use the Network panel to inspect the method, final URL, request payload, status, response headers, and response body. If needed, reproduce the request with a safe API client, but redact credentials before sharing logs or commands.
Security and performance
Authorization must be enforced per resource on the server. Hiding a button is a UI decision, not access control. Use HTTPS, keep secrets out of URLs and frontend bundles, validate IDs and bodies, apply request-size and rate limits, and return generic server errors without stack traces. CORS controls whether a browser shares a response with frontend code; it is not authentication. Cookie-authenticated state-changing operations also need CSRF protection.
For performance, paginate collections, support filtering, use HTTP cache validators where appropriate, avoid nested request waterfalls, and bound concurrency. Retry operations only when their semantics and the application design make that safe; automatic POST retries can create duplicates. Where supported, use conditional requests such as If-Match to protect production updates from lost writes.
Exercises
Run Level 2 inside an async function or an async browser-console entry. For a page example, serve the folder over HTTP and replace /users with an endpoint you control; the snippet by itself does not create a server.
Level 1: design endpoints
Choose the method and path to retrieve book 8, list books by author 3, and delete book 8.
GET /books/8
GET /books?authorId=3
DELETE /books/8
The item ID belongs in a path segment. Filtering a collection belongs in a query parameter.
Level 2: create JSON
Write a Fetch request that creates { "name": "Ada" } under /users.
const response = await fetch("/users", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({ name: "Ada" }),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
Level 3: handle multiple success forms
Write a helper that accepts response plus { method, responseType, allowStructuredJson }. It must throw for non-2xx responses; return null for HEAD, 204, 205, or an endpoint with responseType: "none"; and parse JSON otherwise only when the complete media type matches the endpoint contract.
function isJsonMediaType(value, allowStructuredSuffix = false) {
if (typeof value !== "string") return false;
const mediaType = value.split(";", 1)[0].trim().toLowerCase();
const subtype = mediaType.startsWith("application/")
? mediaType.slice("application/".length)
: "";
return mediaType === "application/json" ||
(allowStructuredSuffix &&
subtype.length > "+json".length &&
subtype.endsWith("+json"));
}
async function readApiResponse(
response,
{ method = "GET", responseType = "json", allowStructuredJson = false } = {},
) {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
if (
method.toUpperCase() === "HEAD" ||
response.status === 204 ||
response.status === 205 ||
responseType === "none"
) {
return null;
}
if (responseType !== "json") {
throw new TypeError(`Unsupported response type: ${responseType}`);
}
const type = response.headers.get("content-type");
if (!isJsonMediaType(type, allowStructuredJson)) {
throw new TypeError("Expected JSON");
}
return response.json();
}
Recap
An HTTP API exposes resources through interactions that combine a method and a URI. Methods carry standardized intent, paths identify targets, and queries commonly select or shape representations. JSON is one representation format; it is not REST itself.
Reliable clients use accurate method semantics, handle more than one success and error status, declare and verify media types, render external data safely, enforce authorization and limits on the server, and expose failure states instead of hiding them.
Official references
- RFC 9110: HTTP Semantics
- RFC 9110: Resources
- RFC 9110: Methods
- RFC 9110: Status codes
- RFC 5789: PATCH
- RFC 8259: JSON
- MDN: HTTP request methods
- MDN: HTTP response status codes
- WHATWG Fetch Standard
Reliability is part of the API contract
An API client should make its reliability policy explicit: which failures are retryable, how many attempts are allowed, and how cancellation reaches every request. A retry is not harmless merely because the client never received a response. GET, HEAD, PUT, and DELETE are idempotent by HTTP semantics, but application side effects, rate limits, and server bugs still require judgment. A server can make POST safely retryable with an idempotency key if that behavior is part of its contract.
async function getWithRetry(url, { signal, attempts = 3 } = {}) {
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
const response = await fetch(url, { signal, headers: { Accept: "application/json" } });
if (response.ok) return response;
const retryable = response.status === 408 || response.status === 429 || response.status >= 500;
if (!retryable || attempt === attempts) {
const error = new Error(`HTTP ${response.status}`);
error.retryable = retryable;
throw error;
}
const retryAfter = Number(response.headers.get("Retry-After"));
const delay = Number.isFinite(retryAfter) ? retryAfter * 1000 : 100 * 2 ** (attempt - 1);
await new Promise((resolve, reject) => {
const timer = setTimeout(resolve, delay);
signal?.addEventListener("abort", () => {
clearTimeout(timer);
reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
}, { once: true });
});
} catch (error) {
if (error.name === "AbortError" || error.retryable === false || attempt === attempts) {
throw error;
}
}
}
}
The example intentionally does not retry arbitrary 400 errors, and it propagates abort. A production helper should also parse Retry-After dates as well as seconds, cap the delay, add jitter, and avoid consuming a response body twice.
Testable API boundary
Inject fetch rather than hard-coding it. Tests can then use deterministic responses and do not depend on a public service being available:
async function readJson(fetcher, url) {
const response = await fetcher(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}
const fakeFetch = async () => Response.json({ id: 1 }, { status: 200 });
readJson(fakeFetch, "/users/1").then((value) => {
console.assert(value.id === 1);
});
Add fake responses for 401, 500, malformed JSON, a rejected Promise, and an abort. Define the expected result for each case. A green UI alone is not an API test.
Interview questions
- Why should a client not retry every
500? The operation may be non-idempotent, the outage may be persistent, or retries may amplify load. - What is the difference between
401and403?401indicates missing or invalid authentication;403means the server understood the request but refuses authorization. - Why are CORS, authentication, and authorization separate? CORS is a browser response-sharing policy; authentication identifies a caller; authorization decides what that caller may do.
- How do you test a Fetch client reliably? Inject the transport and return deterministic
Responseobjects or rejections for success, HTTP, parse, network, and abort paths.
API boundaries and async state
A Fetch Promise fulfills when an HTTP response arrives, including a 404 or 500. Check response.ok or status before treating the parsed value as an application success. In the client state model, distinguish network failure, HTTP failure, invalid JSON, an invalid data shape, cancellation, and a stale result from an older request.
Use a local deterministic fixture for the required exercise. Add tests for valid success, invalid success, 401, 500, network rejection, abort, and the case where an older request completes after a newer request. A required lesson should not depend on public API uptime as its source of truth.
