From Zero to AI

Lesson 4.6: Practice - JSONPlaceholder API

Duration: 75 minutes

Learning Objectives

By the end of this lesson, you will be able to:

  1. Work with a real REST API (JSONPlaceholder)
  2. Implement CRUD operations in TypeScript
  3. Handle relationships between resources
  4. Build a complete API client application
  5. Apply error handling and loading states

Introduction

In this practice lesson, we will build a complete application that interacts with JSONPlaceholder - a free fake REST API for testing and prototyping. You will apply everything you have learned about HTTP methods, Fetch API, and Axios.

JSONPlaceholder provides these resources:

  • /posts - 100 posts
  • /comments - 500 comments
  • /users - 10 users
  • /todos - 200 todos
  • /albums - 100 albums
  • /photos - 5000 photos

JSONPlaceholder Overview

Base URL: https://jsonplaceholder.typicode.com

Available Endpoints

Resource Endpoint Methods
Posts /posts GET, POST
Posts /posts/:id GET, PUT, PATCH, DELETE
Comments /comments GET, POST
Post Comments /posts/:id/comments GET
Users /users GET
Users /users/:id GET
User Posts /users/:id/posts GET
Todos /todos GET, POST

Data Structures

interface Post {
  id: number;
  userId: number;
  title: string;
  body: string;
}

interface Comment {
  id: number;
  postId: number;
  name: string;
  email: string;
  body: string;
}

interface User {
  id: number;
  name: string;
  username: string;
  email: string;
  address: {
    street: string;
    suite: string;
    city: string;
    zipcode: string;
    geo: {
      lat: string;
      lng: string;
    };
  };
  phone: string;
  website: string;
  company: {
    name: string;
    catchPhrase: string;
    bs: string;
  };
}

interface Todo {
  id: number;
  userId: number;
  title: string;
  completed: boolean;
}

Project: Blog API Client

We will build a TypeScript application that:

  1. Displays a list of users
  2. Shows posts by a selected user
  3. Displays comments on posts
  4. Allows creating, updating, and deleting posts

Project Structure

blog-client/
├── src/
│   ├── types.ts       # Type definitions
│   ├── api.ts         # API client
│   ├── utils.ts       # Helper functions
│   └── index.ts       # Main application
├── package.json
└── tsconfig.json

Step 1: Define Types

// src/types.ts

export interface User {
  id: number;
  name: string;
  username: string;
  email: string;
  address: Address;
  phone: string;
  website: string;
  company: Company;
}

export interface Address {
  street: string;
  suite: string;
  city: string;
  zipcode: string;
  geo: {
    lat: string;
    lng: string;
  };
}

export interface Company {
  name: string;
  catchPhrase: string;
  bs: string;
}

export interface Post {
  id: number;
  userId: number;
  title: string;
  body: string;
}

export interface Comment {
  id: number;
  postId: number;
  name: string;
  email: string;
  body: string;
}

export interface Todo {
  id: number;
  userId: number;
  title: string;
  completed: boolean;
}

// Request types (without id, since server assigns it)
export interface CreatePostData {
  userId: number;
  title: string;
  body: string;
}

export interface UpdatePostData {
  title?: string;
  body?: string;
}

export interface CreateCommentData {
  postId: number;
  name: string;
  email: string;
  body: string;
}

Step 2: Build the API Client

// src/api.ts
import {
  Comment,
  CreateCommentData,
  CreatePostData,
  Post,
  Todo,
  UpdatePostData,
  User,
} from './types';

const BASE_URL = 'https://jsonplaceholder.typicode.com';

class ApiError extends Error {
  constructor(
    public status: number,
    public statusText: string,
    message?: string
  ) {
    super(message || `HTTP Error: ${status} ${statusText}`);
    this.name = 'ApiError';
  }
}

async function request<T>(endpoint: string, options?: RequestInit): Promise<T> {
  const url = `${BASE_URL}${endpoint}`;

  const response = await fetch(url, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      ...options?.headers,
    },
  });

  if (!response.ok) {
    throw new ApiError(response.status, response.statusText);
  }

  // Handle empty responses (DELETE)
  const text = await response.text();
  return text ? JSON.parse(text) : ({} as T);
}

// ============ Users ============

export async function getUsers(): Promise<User[]> {
  return request<User[]>('/users');
}

export async function getUser(id: number): Promise<User> {
  return request<User>(`/users/${id}`);
}

// ============ Posts ============

export async function getPosts(): Promise<Post[]> {
  return request<Post[]>('/posts');
}

export async function getPost(id: number): Promise<Post> {
  return request<Post>(`/posts/${id}`);
}

export async function getUserPosts(userId: number): Promise<Post[]> {
  return request<Post[]>(`/users/${userId}/posts`);
}

export async function createPost(data: CreatePostData): Promise<Post> {
  return request<Post>('/posts', {
    method: 'POST',
    body: JSON.stringify(data),
  });
}

export async function updatePost(id: number, data: UpdatePostData): Promise<Post> {
  return request<Post>(`/posts/${id}`, {
    method: 'PATCH',
    body: JSON.stringify(data),
  });
}

export async function deletePost(id: number): Promise<void> {
  await request<{}>(`/posts/${id}`, {
    method: 'DELETE',
  });
}

// ============ Comments ============

export async function getComments(): Promise<Comment[]> {
  return request<Comment[]>('/comments');
}

export async function getPostComments(postId: number): Promise<Comment[]> {
  return request<Comment[]>(`/posts/${postId}/comments`);
}

export async function createComment(data: CreateCommentData): Promise<Comment> {
  return request<Comment>('/comments', {
    method: 'POST',
    body: JSON.stringify(data),
  });
}

// ============ Todos ============

export async function getTodos(): Promise<Todo[]> {
  return request<Todo[]>('/todos');
}

export async function getUserTodos(userId: number): Promise<Todo[]> {
  return request<Todo[]>(`/users/${userId}/todos`);
}

export async function toggleTodo(id: number, completed: boolean): Promise<Todo> {
  return request<Todo>(`/todos/${id}`, {
    method: 'PATCH',
    body: JSON.stringify({ completed }),
  });
}

Step 3: Helper Functions

// src/utils.ts
import { Comment, Post, User } from './types';

export function formatPost(post: Post): string {
  return `
[${post.id}] ${post.title}
${'-'.repeat(50)}
${post.body}
`;
}

export function formatComment(comment: Comment): string {
  return `  > ${comment.name} (${comment.email})
    ${comment.body}`;
}

export function formatUser(user: User): string {
  return `${user.name} (@${user.username}) - ${user.email}`;
}

export function formatUserDetails(user: User): string {
  return `
Name: ${user.name}
Username: @${user.username}
Email: ${user.email}
Phone: ${user.phone}
Website: ${user.website}
Address: ${user.address.street}, ${user.address.suite}, ${user.address.city} ${user.address.zipcode}
Company: ${user.company.name}
`;
}

export function truncate(text: string, maxLength: number): string {
  if (text.length <= maxLength) return text;
  return text.slice(0, maxLength - 3) + '...';
}

export async function measureTime<T>(fn: () => Promise<T>, label: string): Promise<T> {
  const start = performance.now();
  const result = await fn();
  const duration = (performance.now() - start).toFixed(2);
  console.log(`${label}: ${duration}ms`);
  return result;
}

Step 4: Main Application

// src/index.ts
import * as api from './api';
import { formatComment, formatPost, formatUser, formatUserDetails, measureTime } from './utils';

// ============ Demo Functions ============

async function demoGetAllUsers(): Promise<void> {
  console.log('\n=== All Users ===\n');

  const users = await measureTime(() => api.getUsers(), 'Fetched users');

  users.forEach((user, index) => {
    console.log(`${index + 1}. ${formatUser(user)}`);
  });
}

async function demoGetUserDetails(userId: number): Promise<void> {
  console.log(`\n=== User ${userId} Details ===`);

  const user = await api.getUser(userId);
  console.log(formatUserDetails(user));
}

async function demoGetUserPosts(userId: number): Promise<void> {
  console.log(`\n=== Posts by User ${userId} ===\n`);

  const [user, posts] = await Promise.all([api.getUser(userId), api.getUserPosts(userId)]);

  console.log(`Posts by ${user.name}:\n`);

  posts.slice(0, 3).forEach((post) => {
    console.log(formatPost(post));
  });

  console.log(`... and ${posts.length - 3} more posts`);
}

async function demoGetPostWithComments(postId: number): Promise<void> {
  console.log(`\n=== Post ${postId} with Comments ===\n`);

  const [post, comments] = await Promise.all([api.getPost(postId), api.getPostComments(postId)]);

  console.log(formatPost(post));
  console.log(`\nComments (${comments.length}):\n`);

  comments.slice(0, 3).forEach((comment) => {
    console.log(formatComment(comment));
    console.log();
  });
}

async function demoCRUDOperations(): Promise<void> {
  console.log('\n=== CRUD Operations Demo ===\n');

  // CREATE
  console.log('1. Creating a new post...');
  const newPost = await api.createPost({
    userId: 1,
    title: 'My New Post',
    body: 'This is the content of my new post.',
  });
  console.log(`Created post with ID: ${newPost.id}`);
  console.log(formatPost(newPost));

  // READ
  console.log('\n2. Reading the post...');
  const fetchedPost = await api.getPost(newPost.id);
  console.log(`Fetched: ${fetchedPost.title}`);

  // UPDATE
  console.log('\n3. Updating the post title...');
  const updatedPost = await api.updatePost(newPost.id, {
    title: 'My Updated Post Title',
  });
  console.log(`Updated title: ${updatedPost.title}`);

  // DELETE
  console.log('\n4. Deleting the post...');
  await api.deletePost(newPost.id);
  console.log('Post deleted successfully!');
}

async function demoParallelRequests(): Promise<void> {
  console.log('\n=== Parallel Requests Demo ===\n');

  console.log('Fetching multiple users in parallel...');

  const userIds = [1, 2, 3, 4, 5];

  const users = await measureTime(
    () => Promise.all(userIds.map((id) => api.getUser(id))),
    'Fetched 5 users in parallel'
  );

  users.forEach((user) => {
    console.log(`- ${formatUser(user)}`);
  });
}

async function demoUserDashboard(userId: number): Promise<void> {
  console.log(`\n=== Dashboard for User ${userId} ===\n`);

  // Fetch all data in parallel
  const [user, posts, todos] = await measureTime(
    () => Promise.all([api.getUser(userId), api.getUserPosts(userId), api.getUserTodos(userId)]),
    'Fetched user dashboard data'
  );

  console.log(`Welcome, ${user.name}!\n`);

  // Posts summary
  console.log(`Posts: ${posts.length}`);
  console.log('Recent posts:');
  posts.slice(0, 3).forEach((post) => {
    console.log(`  - ${post.title.substring(0, 40)}...`);
  });

  // Todos summary
  const completedTodos = todos.filter((t) => t.completed).length;
  console.log(`\nTodos: ${completedTodos}/${todos.length} completed`);

  const pendingTodos = todos.filter((t) => !t.completed).slice(0, 3);
  console.log('Pending tasks:');
  pendingTodos.forEach((todo) => {
    console.log(`  [ ] ${todo.title.substring(0, 40)}...`);
  });
}

async function demoErrorHandling(): Promise<void> {
  console.log('\n=== Error Handling Demo ===\n');

  // Try to fetch a non-existent user
  try {
    console.log('Attempting to fetch user 999...');
    await api.getUser(999);
  } catch (error) {
    if (error instanceof Error) {
      console.log(`Error caught: ${error.message}`);
    }
  }

  // Try to fetch a non-existent post
  try {
    console.log('\nAttempting to fetch post 999...');
    await api.getPost(999);
  } catch (error) {
    if (error instanceof Error) {
      console.log(`Error caught: ${error.message}`);
    }
  }
}

// ============ Main Entry Point ============

async function main(): Promise<void> {
  console.log('Blog API Client Demo');
  console.log('====================');

  try {
    // Run all demos
    await demoGetAllUsers();
    await demoGetUserDetails(1);
    await demoGetUserPosts(1);
    await demoGetPostWithComments(1);
    await demoCRUDOperations();
    await demoParallelRequests();
    await demoUserDashboard(1);
    await demoErrorHandling();

    console.log('\n=== All demos completed! ===\n');
  } catch (error) {
    console.error('An error occurred:', error);
  }
}

main();

Step 5: Running the Application

# Initialize project
npm init -y
npm install typescript ts-node @types/node
npx tsc --init

# Run the application
npx ts-node src/index.ts

Exercises

Exercise 1: Search Posts

Add a function to search posts by title:

async function searchPosts(query: string): Promise<Post[]> {
  // Fetch all posts and filter by title containing query
}

// Test
const results = await searchPosts('qui');
console.log(`Found ${results.length} posts matching "qui"`);
Solution
async function searchPosts(query: string): Promise<Post[]> {
  const posts = await api.getPosts();
  const lowerQuery = query.toLowerCase();

  return posts.filter(
    (post) =>
      post.title.toLowerCase().includes(lowerQuery) || post.body.toLowerCase().includes(lowerQuery)
  );
}

// Test
const results = await searchPosts('qui');
console.log(`Found ${results.length} posts matching "qui"`);
results.slice(0, 5).forEach((post) => {
  console.log(`- [${post.id}] ${post.title}`);
});

Exercise 2: User Statistics

Create a function that returns statistics for a user:

interface UserStats {
  user: User;
  totalPosts: number;
  totalTodos: number;
  completedTodos: number;
  completionRate: number; // percentage
}

async function getUserStats(userId: number): Promise<UserStats> {}
Solution
interface UserStats {
  user: User;
  totalPosts: number;
  totalTodos: number;
  completedTodos: number;
  completionRate: number;
}

async function getUserStats(userId: number): Promise<UserStats> {
  const [user, posts, todos] = await Promise.all([
    api.getUser(userId),
    api.getUserPosts(userId),
    api.getUserTodos(userId),
  ]);

  const completedTodos = todos.filter((t) => t.completed).length;

  return {
    user,
    totalPosts: posts.length,
    totalTodos: todos.length,
    completedTodos,
    completionRate: todos.length > 0 ? Math.round((completedTodos / todos.length) * 100) : 0,
  };
}

// Test
const stats = await getUserStats(1);
console.log(`
User: ${stats.user.name}
Posts: ${stats.totalPosts}
Todos: ${stats.completedTodos}/${stats.totalTodos} (${stats.completionRate}%)
`);

Exercise 3: Bulk Operations

Create functions to perform bulk operations:

// Create multiple posts at once
async function createMultiplePosts(posts: CreatePostData[]): Promise<Post[]> {
  // Create all posts in parallel
}

// Delete multiple posts
async function deleteMultiplePosts(ids: number[]): Promise<void> {
  // Delete all posts in parallel
}
Solution
async function createMultiplePosts(postsData: CreatePostData[]): Promise<Post[]> {
  return Promise.all(postsData.map((data) => api.createPost(data)));
}

async function deleteMultiplePosts(ids: number[]): Promise<void> {
  await Promise.all(ids.map((id) => api.deletePost(id)));
}

// Test
const newPosts = await createMultiplePosts([
  { userId: 1, title: 'Post 1', body: 'Content 1' },
  { userId: 1, title: 'Post 2', body: 'Content 2' },
  { userId: 1, title: 'Post 3', body: 'Content 3' },
]);

console.log(`Created ${newPosts.length} posts`);

await deleteMultiplePosts(newPosts.map((p) => p.id));
console.log('Deleted all created posts');

Exercise 4: Leaderboard

Create a leaderboard showing users ranked by activity:

interface LeaderboardEntry {
  user: User;
  postCount: number;
  completedTodoCount: number;
  score: number; // postCount * 10 + completedTodoCount
}

async function getLeaderboard(): Promise<LeaderboardEntry[]> {
  // Get all users and their activity, then rank them
}
Solution
interface LeaderboardEntry {
  user: User;
  postCount: number;
  completedTodoCount: number;
  score: number;
}

async function getLeaderboard(): Promise<LeaderboardEntry[]> {
  const users = await api.getUsers();

  // Fetch posts and todos for all users in parallel
  const [allPosts, allTodos] = await Promise.all([api.getPosts(), api.getTodos()]);

  const entries: LeaderboardEntry[] = users.map((user) => {
    const userPosts = allPosts.filter((p) => p.userId === user.id);
    const userTodos = allTodos.filter((t) => t.userId === user.id);
    const completedTodos = userTodos.filter((t) => t.completed);

    return {
      user,
      postCount: userPosts.length,
      completedTodoCount: completedTodos.length,
      score: userPosts.length * 10 + completedTodos.length,
    };
  });

  // Sort by score descending
  return entries.sort((a, b) => b.score - a.score);
}

// Test
const leaderboard = await getLeaderboard();
console.log('\n=== Leaderboard ===\n');
leaderboard.forEach((entry, index) => {
  console.log(
    `${index + 1}. ${entry.user.name} - Score: ${entry.score} ` +
      `(${entry.postCount} posts, ${entry.completedTodoCount} todos)`
  );
});

Exercise 5: Data Export

Create a function to export user data as a summary:

interface UserExport {
  user: User;
  posts: Post[];
  todos: Todo[];
  exportedAt: string;
}

async function exportUserData(userId: number): Promise<UserExport> {}
Solution
interface UserExport {
  user: User;
  posts: Post[];
  todos: Todo[];
  exportedAt: string;
}

async function exportUserData(userId: number): Promise<UserExport> {
  const [user, posts, todos] = await Promise.all([
    api.getUser(userId),
    api.getUserPosts(userId),
    api.getUserTodos(userId),
  ]);

  return {
    user,
    posts,
    todos,
    exportedAt: new Date().toISOString(),
  };
}

// Test
const exportData = await exportUserData(1);
console.log('\n=== User Data Export ===\n');
console.log(`User: ${exportData.user.name}`);
console.log(`Posts: ${exportData.posts.length}`);
console.log(`Todos: ${exportData.todos.length}`);
console.log(`Exported at: ${exportData.exportedAt}`);

// Could save to file:
// import { writeFile } from "fs/promises";
// await writeFile("user-export.json", JSON.stringify(exportData, null, 2));

Challenge: Build a CLI Application

As a bonus challenge, build a simple interactive CLI that allows users to:

  1. List all users
  2. Select a user and view their posts
  3. View comments on a post
  4. Create a new post
  5. Delete a post

Hint: Use readline module for user input:

import * as readline from 'readline';

const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout,
});

function prompt(question: string): Promise<string> {
  return new Promise((resolve) => {
    rl.question(question, resolve);
  });
}

// Usage
const name = await prompt('Enter your name: ');
console.log(`Hello, ${name}!`);

Key Takeaways

  1. JSONPlaceholder is a free API perfect for learning and prototyping
  2. Always define types for API responses in TypeScript
  3. Create reusable API functions for each endpoint
  4. Use Promise.all for parallel requests when data is independent
  5. Error handling is essential - always expect requests to fail
  6. Measure performance to understand API latency
  7. Helper functions make code more readable
  8. Build higher-level functions on top of basic API calls

Resources

Resource Type Level
JSONPlaceholder API Beginner
JSONPlaceholder Guide Documentation Beginner
TypeScript Handbook Documentation Intermediate
Node.js readline Documentation Intermediate

Module Complete

Congratulations! You have completed Module 4: HTTP and REST API. You now know how to:

  • Understand what APIs are and how they work
  • Use HTTP methods (GET, POST, PUT, PATCH, DELETE)
  • Work with headers, query parameters, and request bodies
  • Make HTTP requests with Fetch API and Axios
  • Handle errors and edge cases
  • Build complete applications that interact with APIs

Next Module

Continue your learning journey with Module 5: Working with Data, where you will learn about data transformation, validation with Zod, and more advanced data handling techniques.

Continue to Module 5: Working with Data