REST API Mastery

An interactive deep-dive into authentication, error handling, real-world APIs, and hands-on practice — all in one place.

JWT & OAuth Error Patterns Live APIs 25+ Quizzes Playground

1 · Authentication Module

1.1 Understanding JWT (JSON Web Tokens)

A JWT is a compact, URL-safe token used to represent claims between two parties. It has three Base64URL-encoded parts separated by dots.

JWT Token Structure
Header
{
  "alg": "HS256",
  "typ": "JWT"
}
Payload
{
  "sub": "1234567890",
  "name": "Jane",
  "role": "admin",
  "exp": 1696000000
}
Signature
HMACSHA256(
  base64Url(header)
  + "." +
  base64Url(payload),
  secret
)
💡 The signature is never decoded on the client — it's only verified server-side using the secret key. Clients can freely read the header and payload.

Decoding and inspecting a JWT

JavaScript
function decodeJWT(token) {
  const parts = token.split('.');
  if (parts.length !== 3) {
    throw new Error('Invalid JWT: must have 3 parts separated by dots');
  }

  const decode = (str) => {
    // Restore Base64 chars that were stripped for URL safety
    const padded = str + '='.repeat((4 - str.length % 4) % 4);
    const base64 = padded.replace(/-/g, '+').replace(/_/g, '/');
    return JSON.parse(atob(base64));
  };

  return {
    header: decode(parts[0]),
    payload: decode(parts[1]),
    signature: parts[2],
    // signature is never decoded — verified server-side
  };
}

// Example usage
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.' +
  'eyJzdWIiOiIxMjM0IiwibmFtZSI6IkphbmUiLCJleHAiOjE2OTYwMDAwMDB9.' +
  'fakeSig';
const decoded = decodeJWT(token);
console.log(decoded);
// { header: { alg: 'HS256', typ: 'JWT' },
//   payload: { sub: '1234', name: 'Jane', exp: 1696000000 },
//   signature: 'fakeSig' }

1.2 JWT Authentication Flow

Login → Store → Authenticated Request
Client
Server
Database
1POST /login with email & password
2Server verifies credentials with DB
3DB confirms user exists
4Returns { token: "eyJ..." }
5Client stores token (httpOnly cookie best)
6GET /profile + Authorization: Bearer <jwt>
7Verify signature → check exp → extract user
8200 OK with user data

Implementing the login and authenticated request flow

JavaScript
// ─── Login and token acquisition ────────────────────────────────
async function login(email, password) {
  const res = await fetch('https://api.example.com/auth/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, password }),
  });

  if (!res.ok) throw new Error('Invalid credentials');

  const { token, refreshToken } = await res.json();

  // httpOnly cookie is safest (set by server)
  // localStorage fallback (less secure, XSS risk):
  localStorage.setItem('accessToken', token);
  localStorage.setItem('refreshToken', refreshToken);

  return token;
}

// ─── Authenticated request with auto-refresh ────────────────────
async function fetchWithAuth(url, options = {}) {
  const token = localStorage.getItem('accessToken');

  const res = await fetch(url, {
    ...options,
    headers: {
      ...options.headers,
      'Authorization': `Bearer ${token}`,
    },
  });

  // Handle token expiration
  if (res.status === 401) {
    const newToken = await refreshAccessToken();
    if (newToken) {
      return fetch(url, {
        ...options,
        headers: {
          ...options.headers,
          'Authorization': `Bearer ${newToken}`,
        },
      });
    }
    throw new Error('Session expired. Please log in again.');
  }

  return res;
}

// ─── Refresh token flow ─────────────────────────────────────────
async function refreshAccessToken() {
  const refreshToken = localStorage.getItem('refreshToken');
  if (!refreshToken) return null;

  try {
    const res = await fetch('https://api.example.com/auth/refresh', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ refreshToken }),
    });

    if (!res.ok) {
      localStorage.removeItem('accessToken');
      localStorage.removeItem('refreshToken');
      return null;
    }

    const { token } = await res.json();
    localStorage.setItem('accessToken', token);
    return token;
  } catch (err) {
    console.error('Token refresh failed:', err);
    return null;
  }
}

1.3 OAuth 2.0 Authorization Code Flow

OAuth 2.0 is a delegated authorization framework — users grant third-party apps access to their data without sharing passwords.

OAuth 2.0 Authorization Code + PKCE Flow
User
Your App
Auth Server
Resource API
1User clicks "Login with Google"
2App redirects browser to auth server URL
3"Do you authorize this app?" consent screen
4User approves → redirects back with ?code=AUTH_CODE
5App exchanges code for tokens (server-side)
6Returns { access_token, refresh_token, expires_in }
7API calls with Bearer access_token
8Protected resource data returned
JavaScript
// ─── OAuth 2.0 with PKCE ────────────────────────────────────────
const CLIENT_ID = 'your_client_id';
const REDIRECT_URI = window.location.origin + '/callback';
const AUTH_SERVER = 'https://accounts.google.com/o/oauth2/v2/auth';

function generateCodeVerifier() {
  const array = new Uint8Array(32);
  crypto.getRandomValues(array);
  return base64UrlEncode(array);
}

async function generateCodeChallenge(verifier) {
  const data = new TextEncoder().encode(verifier);
  const digest = await crypto.subtle.digest('SHA-256', data);
  return base64UrlEncode(new Uint8Array(digest));
}

function base64UrlEncode(bytes) {
  return btoa(String.fromCharCode(...bytes))
    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

// ─── Initiate OAuth flow ────────────────────────────────────────
async function initiateOAuth() {
  const state = crypto.randomUUID();        // CSRF protection
  const verifier = generateCodeVerifier();  // PKCE
  const challenge = await generateCodeChallenge(verifier);

  sessionStorage.setItem('oauth_state', state);
  sessionStorage.setItem('oauth_verifier', verifier);

  const params = new URLSearchParams({
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    response_type: 'code',
    scope: 'openid email profile',
    state, code_challenge: challenge,
    code_challenge_method: 'S256',
  });

  window.location.href = `${AUTH_SERVER}?${params}`;
}

// ─── Handle the callback ───────────────────────────────────────
async function handleOAuthCallback() {
  const params = new URLSearchParams(window.location.search);
  const code = params.get('code');
  const state = params.get('state');
  const savedState = sessionStorage.getItem('oauth_state');

  if (!state || state !== savedState) {
    throw new Error('Invalid state — possible CSRF attack');
  }

  const verifier = sessionStorage.getItem('oauth_verifier');

  const res = await fetch('https://oauth2.googleapis.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      client_id: CLIENT_ID,
      grant_type: 'authorization_code',
      code, redirect_uri: REDIRECT_URI,
      code_verifier: verifier,
    }),
  });

  if (!res.ok) {
    const err = await res.json();
    throw new Error(`Token exchange failed: ${err.error_description}`);
  }

  sessionStorage.removeItem('oauth_state');
  sessionStorage.removeItem('oauth_verifier');
  return res.json(); // { access_token, refresh_token, id_token }
}

1.4 Token Storage Comparison

Storage MethodXSS Safe?CSRF Safe?Recommendation
localStorage✗ accessible✓⚠ Avoid for tokens
sessionStorage✗ accessible✓⚠ Short-lived only
httpOnly cookie✓ not JS-readable✗ needs CSRF token✅ Best for tokens
In-memory variable✓✓✅ Best for access tokens
🔑 Best practice: Store the access token in-memory (short-lived, cleared on refresh) and the refresh token in an httpOnly, Secure, SameSite=Strict cookie.

1.5 Complete Authentication Wrapper

JavaScript — AuthenticatedClient class
class AuthenticatedClient {
  constructor(baseUrl, authUrl) {
    this.baseUrl = baseUrl;
    this.authUrl = authUrl;
    this.accessToken = null;       // In-memory only
    this.refreshPromise = null;    // Prevent concurrent refreshes
  }

  async login(credentials) {
    const res = await fetch(`${this.authUrl}/login`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      credentials: 'include', // Receive httpOnly cookie
      body: JSON.stringify(credentials),
    });
    if (!res.ok) throw new Error('Login failed');
    const { accessToken } = await res.json();
    this.accessToken = accessToken;
    return accessToken;
  }

  async request(path, options = {}) {
    const headers = {
      ...options.headers,
      'Authorization': `Bearer ${this.accessToken}`,
    };
    let res = await fetch(`${this.baseUrl}${path}`, { ...options, headers });

    if (res.status === 401 && !options._retried) {
      const newToken = await this.refresh();
      if (newToken) {
        return this.request(path, { ...options, _retried: true });
      }
      throw new Error('Authentication failed — please log in again');
    }
    return res;
  }

  async refresh() {
    if (this.refreshPromise) return this.refreshPromise;
    this.refreshPromise = (async () => {
      try {
        const res = await fetch(`${this.authUrl}/refresh`, {
          method: 'POST', credentials: 'include',
        });
        if (!res.ok) { this.accessToken = null; return null; }
        const { accessToken } = await res.json();
        this.accessToken = accessToken;
        return accessToken;
      } finally { this.refreshPromise = null; }
    })();
    return this.refreshPromise;
  }

  get(path, opts)       { return this.request(path, { ...opts, method: 'GET' }); }
  post(path, body, opts) {
    return this.request(path, {
      ...opts, method: 'POST',
      headers: { 'Content-Type': 'application/json', ...opts?.headers },
      body: JSON.stringify(body),
    });
  }
  put(path, body, opts)  {
    return this.request(path, {
      ...opts, method: 'PUT',
      headers: { 'Content-Type': 'application/json', ...opts?.headers },
      body: JSON.stringify(body),
    });
  }
  delete(path, opts)    { return this.request(path, { ...opts, method: 'DELETE' }); }
}

// Usage
const client = new AuthenticatedClient(
  'https://api.example.com',
  'https://api.example.com/auth'
);
await client.login({ email: 'user@example.com', password: 'secret' });
const profile = await client.get('/profile');

2 · Error Handling Patterns

2.1 The Core Problem: Fetch Only Rejects on Network Errors

⚠️ The #1 Gotcha: fetch() does NOT reject on HTTP error status codes (404, 500, etc.). It only rejects on network failures, CORS errors, or bad URLs.
JavaScript
// fetch() does NOT reject on HTTP error status codes!
fetch('https://api.example.com/nonexistent')
  .then(res => {
    console.log(res.ok);     // false (404)
    console.log(res.status); // 404
    // The promise RESOLVED, not rejected!
  })
  .catch(err => {
    // This only runs for network errors
    console.log('This will NOT run for a 404');
  });

2.2 Pattern 1: Basic Response Check

JavaScript
async function basicFetch(url, options) {
  const res = await fetch(url, options);

  if (!res.ok) {
    throw new Error(`HTTP ${res.status}: ${res.statusText}`);
  }

  return res.json();
}

// Problem: "HTTP 404: Not Found" has no context
// Problem: Loses the response body (which may contain error details)

2.3 Pattern 2: Rich Error Extraction

JavaScript
class APIError extends Error {
  constructor(message, { status, statusText, url, body, headers }) {
    super(message);
    this.name = 'APIError';
    this.status = status;
    this.statusText = statusText;
    this.url = url;
    this.body = body;       // Parsed error response from server
    this.headers = headers;
  }
}

async function fetchWithErrorHandling(url, options = {}) {
  let res;

  try {
    res = await fetch(url, options);
  } catch (networkError) {
    throw new APIError('Network error: unable to reach the server', {
      status: 0, url,
      body: { type: 'network', originalError: networkError.message },
    });
  }

  if (!res.ok) {
    let errorBody = null;
    const contentType = res.headers.get('content-type') || '';

    if (contentType.includes('application/json')) {
      try { errorBody = await res.json(); } catch { errorBody = null; }
    } else if (contentType.includes('text/')) {
      try { errorBody = await res.text(); } catch { errorBody = null; }
    }

    const serverMessage = errorBody?.message || errorBody?.error || '';
    const message = serverMessage
      ? `HTTP ${res.status} ${res.statusText}: ${serverMessage}`
      : `HTTP ${res.status} ${res.statusText}`;

    throw new APIError(message, {
      status: res.status, statusText: res.statusText, url,
      body: errorBody,
      headers: Object.fromEntries(res.headers.entries()),
    });
  }

  return res;
}

2.4 Pattern 3: Categorized Error Handling

JavaScript
function classifyError(error) {
  if (error.name !== 'APIError') return 'unexpected';
  const { status } = error;
  if (status === 0)   return 'network';
  if (status === 401) return 'unauthenticated';
  if (status === 403) return 'forbidden';
  if (status === 404) return 'not_found';
  if (status === 408) return 'timeout';
  if (status === 409) return 'conflict';
  if (status === 422) return 'validation';
  if (status === 429) return 'rate_limited';
  if (status >= 500)  return 'server_error';
  if (status >= 400)  return 'client_error';
  return 'unknown';
}

function handleAPIError(error, context = {}) {
  const category = classifyError(error);

  switch (category) {
    case 'network':
      showToast('Connection lost. Check your internet.', 'error');
      break;
    case 'unauthenticated':
      redirectToLogin();
      break;
    case 'forbidden':
      showToast('You do not have permission.', 'error');
      break;
    case 'not_found':
      showNotFoundPage(context.resource);
      break;
    case 'rate_limited':
      const wait = error.headers?.['retry-after'] || 60;
      showToast(`Too many requests. Wait ${wait}s.`, 'warning');
      break;
    case 'validation':
      displayValidationErrors(error.body?.errors || []);
      break;
    case 'server_error':
      showToast('Server error. Our team has been notified.', 'error');
      reportToErrorTracking(error, context);
      break;
    default:
      showToast(error.message || 'Unexpected error.', 'error');
  }
}

// ─── Usage ──────────────────────────────────────────────────────
try {
  const data = await fetchWithErrorHandling('/api/users/123');
  renderUser(data);
} catch (error) {
  handleAPIError(error, { resource: 'user', id: '123' });
}

2.5 Pattern 4: Retry with Exponential Backoff

Exponential Backoff Timeline
Attempt 0
Immediate
FAIL 429
→
~1s wait
+ jitter
(0–500ms)
→
Attempt 1
Retry
FAIL 429
→
~2s wait
+ jitter
(0–500ms)
→
Attempt 2
Retry
SUCCESS ✓
💡 Jitter adds random 0–500ms to prevent the thundering herd problem (all clients retrying at the exact same moment).
JavaScript
async function fetchWithRetry(url, options = {}, retries = 3) {
  const {
    retryDelay = 1000,
    maxDelay = 30000,
    retryableStatuses = [408, 425, 429, 500, 502, 503, 504],
    onRetry,
  } = options.retryConfig || {};

  let lastError;

  for (let attempt = 0; attempt <= retries; attempt++) {
    try {
      const res = await fetch(url, {
        ...options, retryConfig: undefined,
      });

      if (retryableStatuses.includes(res.status) && attempt < retries) {
        const delay = Math.min(
          retryDelay * Math.pow(2, attempt) + Math.random() * 500,
          maxDelay
        );
        const retryAfter = res.headers.get('retry-after');
        const finalDelay = retryAfter
          ? parseInt(retryAfter, 10) * 1000 : delay;

        onRetry?.({ attempt: attempt + 1, delay: finalDelay, status: res.status });
        await new Promise(r => setTimeout(r, finalDelay));
        continue;
      }
      return res;

    } catch (error) {
      if (attempt < retries) {
        const delay = Math.min(
          retryDelay * Math.pow(2, attempt) + Math.random() * 500,
          maxDelay
        );
        onRetry?.({ attempt: attempt + 1, delay, error: error.message });
        await new Promise(r => setTimeout(r, delay));
        lastError = error;
        continue;
      }
      throw error;
    }
  }
  throw lastError;
}

2.6 Pattern 5: Timeout with AbortController

JavaScript
function fetchWithTimeout(url, options = {}, timeout = 10000) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeout);

  return fetch(url, {
    ...options,
    signal: controller.signal,
  }).finally(() => clearTimeout(timeoutId));
}

// ─── Combine timeout with retry ─────────────────────────────────
async function fetchRobust(url, options = {}) {
  const { timeout = 10000, retries = 3, ...fetchOptions } = options;

  for (let attempt = 0; attempt <= retries; attempt++) {
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), timeout);

    try {
      const res = await fetch(url, {
        ...fetchOptions, signal: controller.signal,
      });
      clearTimeout(timeoutId);
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      return res;
    } catch (error) {
      clearTimeout(timeoutId);
      const isLast = attempt === retries;
      const isRetryable = error.name === 'AbortError'
        || error instanceof TypeError;

      if (isLast || !isRetryable) throw error;
      await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt)));
    }
  }
}

2.7 Pattern 6: Circuit Breaker

Circuit Breaker State Machine
Closed
Requests flow through normally
failures ≥ threshold
→
Open
Requests blocked instantly
timeout elapsed
→
Half-Open
1 trial request allowed
success → back to CLOSED  │  failure → back to OPEN
JavaScript
class CircuitBreaker {
  constructor(fetchFn, options = {}) {
    this.fetchFn = fetchFn;
    this.failureThreshold = options.failureThreshold || 5;
    this.resetTimeout = options.resetTimeout || 30000;
    this.monitorWindow = options.monitorWindow || 60000;
    this.state = 'CLOSED';
    this.failures = [];
    this.nextAttempt = null;
  }

  async call(...args) {
    if (this.state === 'OPEN') {
      if (Date.now() < this.nextAttempt) {
        throw new Error('Circuit breaker is OPEN — request blocked');
      }
      this.state = 'HALF_OPEN';
    }

    try {
      const result = await this.fetchFn(...args);
      this.onSuccess();
      return result;
    } catch (error) {
      this.onFailure();
      throw error;
    }
  }

  onSuccess() {
    this.failures = [];
    this.state = 'CLOSED';
  }

  onFailure() {
    const now = Date.now();
    this.failures.push(now);
    this.failures = this.failures.filter(
      t => now - t < this.monitorWindow
    );
    if (this.failures.length >= this.failureThreshold) {
      this.state = 'OPEN';
      this.nextAttempt = now + this.resetTimeout;
    }
  }

  getState() {
    return {
      state: this.state,
      failures: this.failures.length,
      nextAttempt: this.nextAttempt,
    };
  }
}

2.8 Pattern 7: Unified Error Handling Wrapper

JavaScript
async function apiRequest(url, options = {}) {
  const {
    timeout = 10000, retries = 3,
    expectedStatus = [200, 201, 204],
    ...fetchOptions
  } = options;

  let lastError;

  for (let attempt = 0; attempt <= retries; attempt++) {
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), timeout);

    try {
      const res = await fetch(url, {
        ...fetchOptions, signal: controller.signal,
      });
      clearTimeout(timeoutId);

      if (expectedStatus.includes(res.status)) {
        return {
          ok: true, status: res.status,
          data: await parseBody(res), headers: res.headers,
        };
      }

      const errorBody = await parseBody(res).catch(() => null);
      lastError = { status: res.status, message: errorBody?.message || res.statusText, body: errorBody };

      const isRetryable = res.status === 408 || res.status === 429 || res.status >= 500;
      if (!isRetryable || attempt === retries) {
        return { ok: false, status: res.status, error: lastError };
      }
    } catch (error) {
      clearTimeout(timeoutId);
      lastError = { status: 0, message: error.name === 'AbortError' ? 'Timeout' : 'Network error' };
      if (attempt === retries) return { ok: false, status: 0, error: lastError };
    }

    await new Promise(r => setTimeout(r,
      Math.min(1000 * Math.pow(2, attempt) + Math.random() * 500, 30000)
    ));
  }
  return { ok: false, status: 0, error: lastError };
}

async function parseBody(res) {
  const ct = res.headers.get('content-type') || '';
  if (ct.includes('application/json')) return res.json();
  if (ct.includes('text/')) return res.text();
  return null;
}

// ─── Usage ──────────────────────────────────────────────────────
const result = await apiRequest('/api/users', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'Jane', email: 'jane@test.com' }),
  timeout: 15000, retries: 2,
});

if (result.ok) {
  console.log('Created user:', result.data);
} else {
  console.error('Failed:', result.error.message);
}

3 · Real-World APIs

3.1 JSONPlaceholder — REST CRUD Practice

A free fake API for testing and prototyping — perfect for learning REST conventions.

JavaScript — Live
// GET posts with pagination
const res = await fetch(
  'https://jsonplaceholder.typicode.com/posts?_page=1&_limit=3'
);
const posts = await res.json();
console.log(`Fetched ${posts.length} posts:`);
posts.forEach(p => console.log(`  #${p.id}: ${p.title.slice(0, 50)}...`));
JavaScript — Live
// GET single post with comments (parallel)
const [postRes, commentsRes] = await Promise.all([
  fetch('https://jsonplaceholder.typicode.com/posts/1'),
  fetch('https://jsonplaceholder.typicode.com/posts/1/comments'),
]);
const post = await postRes.json();
const comments = await commentsRes.json();
console.log(`Post: "${post.title}"`);
console.log(`Comments: ${comments.length}`);
comments.slice(0, 2).forEach(c => console.log(`  💬 ${c.name}`));
JavaScript — Live
// POST — create a new resource
const res = await fetch('https://jsonplaceholder.typicode.com/posts', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    title: 'My New Post',
    body: 'Learning REST APIs is awesome!',
    userId: 1,
  }),
});
const created = await res.json();
console.log('Created post:', JSON.stringify(created, null, 2));

3.2 GitHub API — Authenticated Requests & Pagination

JavaScript — Live
// Unauthenticated — get public repos (60 req/hour limit)
const res = await fetch(
  'https://api.github.com/users/octocat/repos?per_page=5&sort=updated'
);
const remaining = res.headers.get('x-ratelimit-remaining');
const repos = await res.json();
console.log(`Rate limit remaining: ${remaining}`);
repos.forEach(r =>
  console.log(`  ⭐ ${r.stargazers_count} — ${r.name}: ${r.description || 'No desc'}`)
);
JavaScript — Pagination helper
// Parse GitHub's Link header for cursor-based pagination
function parseNextLink(linkHeader) {
  if (!linkHeader) return null;
  const links = linkHeader.split(',').map(l => l.trim());
  for (const link of links) {
    const match = link.match(/<([^>]+)>;\s*rel="next"/);
    if (match) return match[1].replace('https://api.github.com', '');
  }
  return null;
}

async function getAllRepos(username, token) {
  let allRepos = [];
  let url = `/users/${username}/repos?per_page=100&sort=updated`;

  while (url) {
    const res = await githubClient(url, token);
    const repos = await res.json();
    allRepos.push(...repos);
    url = parseNextLink(res.headers.get('link'));
  }
  return allRepos;
}

3.3 REST Countries — Filtering and Search

JavaScript — Live
// Search countries by region with specific fields
const res = await fetch(
  'https://restcountries.com/v3.1/region/europe?fields=name,capital,population,flag'
);
const countries = await res.json();
// Sort by population descending
countries.sort((a, b) => b.population - a.population);
console.log('Top 8 European countries by population:');
countries.slice(0, 8).forEach(c =>
  console.log(`  ${c.flag} ${c.name.common}: ${(c.population / 1e6).toFixed(1)}M — Capital: ${c.capital?.[0] || 'N/A'}`)
);

3.4 OpenWeatherMap — API Key Auth & Query Params

🔑 OpenWeatherMap requires a free API key. Sign up at openweathermap.org to get one. The code below shows the pattern.
JavaScript
class WeatherClient {
  constructor(apiKey) {
    this.apiKey = apiKey;
    this.baseUrl = 'https://api.openweathermap.org/data/2.5';
  }

  async getCurrentWeather(city, units = 'metric') {
    const params = new URLSearchParams({
      q: city, appid: this.apiKey, units,
    });
    const res = await fetch(`${this.baseUrl}/weather?${params}`);

    if (res.status === 401) throw new Error('Invalid API key');
    if (res.status === 404) throw new Error(`City "${city}" not found`);
    if (res.status === 429) throw new Error('Rate limit exceeded');

    return res.json();
  }

  async getForecastByCity(city) {
    const current = await this.getCurrentWeather(city);
    const { lat, lon } = current.coord;
    const params = new URLSearchParams({
      lat, lon, appid: this.apiKey, units: 'metric',
    });
    const res = await fetch(`${this.baseUrl}/forecast?${params}`);
    return { current, forecast: await res.json() };
  }
}

3.5 Hacker News — Firebase-style Nested Fetching

JavaScript — Live
// Get top 5 Hacker News stories (with concurrency control)
const idsRes = await fetch('https://hacker-news.firebaseio.com/v0/topstories.json');
const ids = await idsRes.json();
const topIds = ids.slice(0, 5);

// Fetch all 5 in parallel
const stories = await Promise.all(
  topIds.map(id =>
    fetch(`https://hacker-news.firebaseio.com/v0/item/${id}.json`)
      .then(r => r.json())
  )
);

console.log('🔥 Top 5 Hacker News stories:');
stories.forEach((s, i) =>
  console.log(`  ${i + 1}. [${s.score}pts] ${s.title}`)
);
JavaScript — Concurrency limiter
// Limit concurrent fetches to avoid overwhelming the server
async function fetchWithConcurrency(items, fetcher, concurrency = 5) {
  const results = new Array(items.length);
  let index = 0;

  async function worker() {
    while (index < items.length) {
      const i = index++;
      results[i] = await fetcher(items[i], i);
    }
  }

  await Promise.all(
    Array.from(
      { length: Math.min(concurrency, items.length) },
      () => worker()
    )
  );
  return results;
}

// Usage: fetch 30 stories, max 5 at a time
const stories = await fetchWithConcurrency(ids.slice(0, 30), async (id) => {
  const res = await fetch(
    `https://hacker-news.firebaseio.com/v0/item/${id}.json`
  );
  return res.json();
}, 5);

4 · Quiz & Challenges

Test your understanding across all sections. Select your answer, then click Check to see the explanation.

4.1 Authentication Quiz

4.2 Error Handling Quiz

4.3 Real-World APIs Quiz

4.4 Code Challenges

Write code that passes the test. Click Run Tests to validate your solution.

Challenge 1: Extract User from JWT

Write a function getUserFromJWT(token) that decodes a JWT and returns the payload's name field. Return null if the token is invalid.

Your Solution
function getUserFromJWT(token) {
  // Your code here

}

Challenge 2: Fetch with Timeout

Write a function fetchWithTimeout(url, ms) that fetches a URL but rejects if the request takes longer than ms milliseconds. Use AbortController.

Your Solution
async function fetchWithTimeout(url, ms) {
  // Your code here

}

Challenge 3: Classify HTTP Status

Write a function classifyStatus(code) that returns one of: "success", "redirect", "client_error", "server_error", or "unknown".

Your Solution
function classifyStatus(code) {
  // Your code here

}

5 · Playground

5.1 API Sandbox

Make live requests to public APIs. Choose a method, enter a URL, add a request body (for POST/PUT), then hit Send.

Request Body (JSON)
Response
Click "Send" to make a request...
Ready

Quick endpoints to try:

5.2 Guided Exercises

Exercise 1: Fetch & Display Users

Use the playground above to GET https://jsonplaceholder.typicode.com/users. Observe the JSON structure — each user has name, email, address, and company fields.

💡Notice how nested objects like address contain sub-fields (street, city, geo). This is a common REST pattern.

Exercise 2: Create → Read → Update → Delete

Perform a full CRUD cycle using JSONPlaceholder:

  1. POST /posts with a JSON body to create a post
  2. GET /posts/1 to read it back
  3. PATCH /posts/1 to update just the title
  4. DELETE /posts/1 to remove it
📝JSONPlaceholder simulates changes but doesn't persist them. You'll get back the correct response shape, but the data resets.

Exercise 3: Query Parameters & Filtering

Experiment with query parameters:

  • ?_page=2&_limit=5 — paginate results
  • ?userId=1 — filter posts by user
  • ?_sort=id&_order=desc — sort results

Try combining them: GET /posts?userId=1&_sort=id&_order=desc&_limit=3

Exercise 4: Error Exploration

Intentionally trigger errors to see how APIs respond:

  • GET /posts/99999 — What does a 404 look like?
  • POST /posts with an empty body — Does it validate?
  • Try an invalid URL — What error shape do you get?

REST API Mastery — Interactive Learning Platform

Built with vanilla HTML, CSS & JavaScript. No frameworks, no dependencies.