Module: JavaScript
JavaScript·083·10 MIN READ

083: REST and API Integration

TOPICS COVERED: REST and API Integration

Learning outcomes

By the end, you can:

  • 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 treating every response as 200;
  • build a small CRUD-style frontend with safe rendering and honest mock fallback behavior.

Retrieval warm-up

  1. Why must Fetch code check response.ok?
  2. What is the difference between Content-Type and Accept?
  3. How can an obsolete Fetch request be canceled?

Vocabulary

  • API: Defined contract through which software components communicate. — Source: MDN: Glossary — API
  • Resource: Target of a request identified by a URI whose state transfers via representations. — Source: RFC 9110 §3.1 Resources
  • Representation: Bytes plus metadata describing current or intended resource state, such as JSON. — Source: RFC 9110 §3.2 Representations
  • Endpoint: Usable method-and-URL combination exposed by an API. — Source: RFC 9110 §9 Methods
  • REST: Architectural style defined by constraints over resources and representations — not any JSON-over-HTTP API. — Source: RFC 9110 §2 Architecture
  • Collection: Resource grouping multiple items addressable at one shared path (/posts). — Source: RFC 9110 §3.1 Resources
  • Path parameter: Variable path segment identifying one item (/posts/42). — Source: RFC 3986 §3.3 Path
  • Query parameter: Query-component pairs filtering or shaping responses (?userId=3). — Source: RFC 3986 §3.4 Query
  • CRUD: Create, Read, Update, Delete — application-level model, not HTTP semantics itself. — Source: MDN: Glossary — CRUD
  • Safe method: Method intended read-only by its semantics (GET, HEAD); no state change expected. — Source: RFC 9110 §9.2.1 Safe
  • Idempotent method: Repeated identical requests have the same intended effect as one (PUT, 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

RFC 9110 separates resource identification from request semantics. A URL identifies the target; the method communicates intent.

text
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

Avoid action-heavy paths like /getPosts when standard method semantics already express the action. Real APIs sometimes model actions as resources, and REST does not require one naming convention, but consistent resource-oriented paths are easier to reason about.

HTTP's definition of DELETE is subtler than "erase database row forever": it asks the server to remove the association between the target resource and its current functionality. Server storage details are implementation concerns.

Self-check: represent a request as four cards: method, target URI, headers, and optional content. Represent the response as status, headers, and optional content. Change one card at a time and explain the new meaning. This helps you see that a URL alone is not the whole endpoint and a JSON body is not the resource itself.

Path versus query

text
/users/3/posts/12           identifies a particular nested item
/posts?userId=3&limit=10    selects or shapes a collection representation

Path parameters usually identify which resource. Query parameters commonly modify which collection members or representation are returned. Query syntax is part of the URI, not private request storage: it appears in browser history, logs, analytics, and caches. Never place passwords or access tokens in a query string.

Methods and status codes

MethodTypical API useSafeIdempotent
GETretrieveyesyes
POSTsubmit/create under server-selected URInono
PUTcreate/replace at target URInoyes
PATCHpartial modificationnonot guaranteed
DELETEdelete target associationnoyes

Idempotent does not mean every response is identical or that no logging occurs. It concerns the intended server effect of repeating an identical request. This matters for retries, but clients still need care because network uncertainty and application behavior can complicate repetition.

Important response codes:

  • 200 OK: successful response with method-dependent content.
  • 201 Created: one or more resources created; usually include Location for the primary new resource.
  • 202 Accepted: accepted for processing, not proof that processing succeeded.
  • 204 No Content: successful response with no response content; do not call response.json() on an empty 204 body.
  • 205 Reset Content: successful response that asks the client to reset its document view and has no response content.
  • 400 Bad Request: malformed or invalid request at a broad level.
  • 401 Unauthorized: authentication is required or invalid; the standardized name is historical and effectively means unauthenticated.
  • 403 Forbidden: server understood but refuses authorization.
  • 404 Not Found: target not found or its existence is intentionally hidden.
  • 405 Method Not Allowed: method is known but not supported for this target.
  • 409 Conflict: request conflicts with current resource state.
  • 415 Unsupported Media Type: request representation format is unsupported.
  • 422 Unprocessable Content: syntax understood but instructions are semantically invalid.
  • 429 Too Many Requests: rate limit exceeded.
  • 500 Internal Server Error and 503 Service Unavailable: server-side failure; 503 commonly represents a temporary condition.

Optional comparison: inspect a posts API

This read-only example uses JSONPlaceholder with fallback data. It is comparison material only; the required CRUD exercise uses the deterministic local repository below.

html
<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

  1. /posts identifies a collection resource.
  2. userId=1 is a query parameter filtering the collection; it is safely serialized with URLSearchParams.
  3. GET requests a representation and has no request body.
  4. Accept: application/json states the preferred response format. It does not guarantee the server complies, so this client uses the 082 isJsonMediaType helper to require exact application/json before parameters, compared case-insensitively.
  5. HTTP and shape errors are made explicit before rendering.
  6. Remote strings go through textContent, not innerHTML.
  7. Fallback data is clearly labeled rather than presented as current server data.

Expected output

Online, the page reports the API's posts for the selected user. Offline, one sample post appears. Public API data and availability can change, so assess the request construction and UI states rather than exact title text.

Intermediate example: CRUD request functions

JSONPlaceholder simulates mutations but does not persist them. These functions demonstrate request contracts:

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

js
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; do not hard-code that every API will echo a representation. requestApi therefore combines protocol rules with an endpoint 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 before any semicolon; an endpoint must set allowStructuredJson: true to also accept application/*+json. The three JSONPlaceholder mutations above 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 merge-like objects are common demonstrations but are not automatically standardized JSON Merge Patch; that format is application/merge-patch+json in RFC 7396.

Required local CRUD repository

Use a local model when the public service is unavailable or persistence behavior matters:

js
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);

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 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 intent.
  • Using POST for all operations: this discards standardized safety and idempotency information.
  • Treating PUT as partial update: standard PUT semantics replace target state; use the API's defined PATCH format for partial changes.
  • Returning 200 for everything: status codes communicate machine-readable outcomes.
  • Parsing no-body responses as JSON: HEAD, 204, and 205 have no response content; an endpoint may also contractually return none for another success status.
  • Matching Content-Type by substring: parse before the semicolon and compare the complete media type case-insensitively; allow application/*+json only when the endpoint contract says so.
  • Confusing 401 and 403: 401 generally requires authentication; 403 refuses the authenticated or understood request.
  • Sending an object directly as Fetch body: serialize JSON and set its content type.
  • Trusting client validation: servers must authenticate, authorize, validate, and enforce limits independently.

Use the Network panel to inspect method, final URL, request payload, status, response headers, and response body. Reproduce with a safe API client when needed, but redact credentials.

Security and performance

Always enforce authorization per resource on the server; hiding buttons is 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 browser response sharing, not authentication. Protect cookie-authenticated state-changing operations against CSRF.

Paginate collections, allow filtering, use HTTP caching validators where appropriate, avoid nested request waterfalls, and bound concurrency. Only retry operations when semantics and application design make it safe; automatic POST retries can create duplicates. Use conditional requests such as If-Match for production lost-update protection when supported.

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 alone does not create a server.

Level 1: design endpoints

Choose method and path to retrieve book 8, list books by author 3, and delete book 8.

text
GET    /books/8
GET    /books?authorId=3
DELETE /books/8

The item ID is a path segment; collection filtering is a query parameter.

Level 2: create JSON

Write a Fetch request to create { "name": "Ada" } under /users.

js
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 accepting 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 otherwise parse JSON only when the complete media type matches the endpoint contract.

js
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 method-and-URI interactions. Methods carry standardized intent; paths identify targets; queries commonly select representations. JSON is one representation format, not REST itself. Design clients around accurate method semantics, varied success and error statuses, explicit media types, safe rendering, authorization, limits, and observable failure states.

Official references

Reliability is part of the API contract

An API client should define which failures are retryable, how many attempts are allowed, and how cancellation reaches every request. A retry is not harmless just because the client did not receive a response. GET, HEAD, PUT, and DELETE are idempotent by HTTP semantics, but application side effects, rate limits, and server bugs still require care. POST can be made safely retryable with an idempotency key if the server implements that contract.

js
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 parse Retry-After dates as well as seconds, cap delay, add jitter, and avoid consuming a response body twice.

Testable API boundary

Inject fetch instead of hard-coding it so tests do not depend on public service uptime:

js
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. The expected result should be explicit for each case; a green UI alone is not an API test.

Interview questions

  1. Why should a client not retry every 500? The operation may be non-idempotent, the outage may be persistent, or retries may amplify load.
  2. What is the difference between 401 and 403? 401 indicates missing/invalid authentication; 403 means the server understood but refuses authorization.
  3. 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.
  4. How do you test a fetch client reliably? Inject the transport and return deterministic Response objects 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 parsing an application success value. Treat network failure, HTTP failure, invalid JSON, invalid data shape, cancellation, and stale results as different states.

Use a local deterministic fixture for the required exercise. Add tests for valid success, invalid success, 401, 500, network rejection, abort, and an older request completing after a newer request. Do not use public API uptime as the source of truth for a required lesson.