REST API Mastery
An interactive deep-dive into authentication, error handling, real-world APIs, and hands-on practice — all in one place.
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.
{
"alg": "HS256",
"typ": "JWT"
}
{
"sub": "1234567890",
"name": "Jane",
"role": "admin",
"exp": 1696000000
}
HMACSHA256( base64Url(header) + "." + base64Url(payload), secret )
Decoding and inspecting a JWT
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
POST /login with email & password{ token: "eyJ..." }GET /profile + Authorization: Bearer <jwt>200 OK with user dataImplementing the login and authenticated request flow
// ─── 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.
?code=AUTH_CODE{ access_token, refresh_token, expires_in }Bearer access_token// ─── 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 Method | XSS 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 |
1.5 Complete Authentication Wrapper
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
fetch() does NOT reject on HTTP error status codes (404, 500, etc.). It only rejects on network failures, CORS errors, or bad URLs.
// 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
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
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
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
FAIL 429
(0–500ms)
FAIL 429
(0–500ms)
SUCCESS ✓
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
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
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
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.
// 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)}...`));
// 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}`));
// 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
// 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'}`)
);
// 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
// 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
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
// 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}`)
);
// 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.
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.
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".
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.
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.
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:
- POST
/postswith a JSON body to create a post - GET
/posts/1to read it back - PATCH
/posts/1to update just the title - DELETE
/posts/1to remove it
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 /postswith an empty body — Does it validate?- Try an invalid URL — What error shape do you get?