Table of Contents
- Prerequisites
- Setting Up the Angular Project
- Understanding Angular’s HttpClientModule
- Creating a Core API Service
- Making HTTP Requests (GET, POST, PUT, DELETE)
- Handling Errors Gracefully
- Using HTTP Interceptors
- Type Safety with Interfaces
- Adding Loading Indicators
- Testing the API Client
- Best Practices
- Conclusion
- References
Prerequisites
Before diving in, ensure you have:
- Basic knowledge of Angular (components, services, modules).
- Node.js (v14+ recommended) and npm installed.
- Angular CLI (install with
npm install -g @angular/cli). - A REST API to test with (we’ll use JSONPlaceholder—a free mock API—for examples).
Setting Up the Angular Project
Let’s start by creating a new Angular project. Open your terminal and run:
ng new angular-api-client --routing --style=css
cd angular-api-client
The --routing flag adds a routing module, and --style=css sets CSS as the stylesheet format (feel free to use scss if preferred).
Next, we’ll need Angular’s HttpClientModule to make HTTP requests. Import it in src/app/app.module.ts:
// src/app/app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { HttpClientModule } from '@angular/common/http'; // Import HttpClientModule
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
@NgModule({
declarations: [AppComponent],
imports: [
BrowserModule,
AppRoutingModule,
HttpClientModule // Add HttpClientModule here
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
Understanding Angular’s HttpClientModule
HttpClientModule is Angular’s built-in module for handling HTTP requests. It provides HttpClient, a service that sends HTTP requests and returns Observables (from RxJS), enabling reactive programming patterns like chaining and cancellation.
Key features of HttpClient:
- Supports all HTTP methods (GET, POST, PUT, DELETE, etc.).
- Automatically parses JSON responses.
- Provides error handling via RxJS operators (e.g.,
catchError). - Supports request/response interceptors for global behavior (e.g., adding headers).
Creating a Core API Service
To keep API logic organized, we’ll create a core service to centralize API calls. This promotes reusability and separation of concerns (components shouldn’t directly fetch data—services should).
Generate a service named api using Angular CLI:
ng generate service core/api
This creates src/app/core/api.service.ts. Update it to inject HttpClient:
// src/app/core/api.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
@Injectable({
providedIn: 'root' // Makes the service a singleton
})
export class ApiService {
// Base URL (use environment variables in production)
private baseUrl = 'https://jsonplaceholder.typicode.com';
constructor(private http: HttpClient) { } // Inject HttpClient
}
The providedIn: 'root' metadata ensures the service is a singleton, available app-wide without adding it to providers in AppModule.
Making HTTP Requests (GET, POST, PUT, DELETE)
Let’s extend ApiService to support common REST operations. We’ll use JSONPlaceholder’s /posts endpoint for examples.
1. GET Request (Fetch Data)
To fetch a list of posts or a single post:
// src/app/core/api.service.ts
// ... (previous code)
// Fetch all posts
getPosts(): Observable<any[]> {
return this.http.get<any[]>(`${this.baseUrl}/posts`);
}
// Fetch a single post by ID
getPostById(id: number): Observable<any> {
return this.http.get<any>(`${this.baseUrl}/posts/${id}`);
}
2. POST Request (Create Data)
To create a new post:
// src/app/core/api.service.ts
// ... (previous code)
// Create a new post
createPost(post: { title: string; body: string; userId: number }): Observable<any> {
return this.http.post<any>(`${this.baseUrl}/posts`, post);
}
3. PUT Request (Update Data)
To update an existing post:
// src/app/core/api.service.ts
// ... (previous code)
// Update a post by ID
updatePost(id: number, post: { title: string; body: string; userId: number }): Observable<any> {
return this.http.put<any>(`${this.baseUrl}/posts/${id}`, post);
}
4. DELETE Request (Delete Data)
To delete a post:
// src/app/core/api.service.ts
// ... (previous code)
// Delete a post by ID
deletePost(id: number): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/posts/${id}`);
}
Using the Service in a Component
Now, let’s use ApiService in AppComponent to display posts. Update src/app/app.component.ts:
// src/app/app.component.ts
import { Component, OnInit } from '@angular/core';
import { ApiService } from './core/api.service';
@Component({
selector: 'app-root',
template: `
<h1>Posts</h1>
<div *ngFor="let post of posts">
<h3>{{ post.title }}</h3>
<p>{{ post.body }}</p>
</div>
`,
styles: []
})
export class AppComponent implements OnInit {
posts: any[] = [];
constructor(private apiService: ApiService) { }
ngOnInit(): void {
this.apiService.getPosts().subscribe({
next: (data) => this.posts = data,
error: (err) => console.error('Error fetching posts:', err)
});
}
}
Run the app with ng serve --open—you’ll see a list of posts fetched from JSONPlaceholder!
Handling Errors Gracefully
Raw HTTP requests can fail (e.g., network errors, 404s). Let’s enhance ApiService to handle errors consistently using RxJS’s catchError operator.
First, import catchError and throwError from RxJS:
// src/app/core/api.service.ts
import { catchError, throwError } from 'rxjs';
// ... (previous code)
Update the getPosts method to include error handling:
getPosts(): Observable<any[]> {
return this.http.get<any[]>(`${this.baseUrl}/posts`).pipe(
catchError((error) => {
console.error('Error fetching posts:', error);
// Custom error handling (e.g., show toast, log to service)
return throwError(() => new Error('Failed to fetch posts. Please try again later.'));
})
);
}
For reusability, create a private error handler method:
// src/app/core/api.service.ts
private handleError(error: any): Observable<never> {
let errorMessage = 'An unknown error occurred!';
if (error.error instanceof ErrorEvent) {
// Client-side error
errorMessage = `Error: ${error.error.message}`;
} else {
// Server-side error
errorMessage = `Error Code: ${error.status}\nMessage: ${error.message}`;
}
console.error(errorMessage);
return throwError(() => new Error(errorMessage));
}
// Update getPosts to use handleError
getPosts(): Observable<any[]> {
return this.http.get<any[]>(`${this.baseUrl}/posts`).pipe(
catchError(this.handleError)
);
}
Using HTTP Interceptors
Interceptors are a powerful feature to modify HTTP requests/responses globally. Common use cases: adding auth headers, logging, or handling errors.
Auth Interceptor (Adding Headers)
To add an authentication token to all requests (e.g., JWT), create an interceptor:
-
Generate an interceptor:
ng generate interceptor core/auth -
Update
src/app/core/auth.interceptor.ts:import { Injectable } from '@angular/core'; import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor } from '@angular/common/http'; import { Observable } from 'rxjs'; @Injectable() export class AuthInterceptor implements HttpInterceptor { constructor() {} intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> { // Get token from local storage (replace with your auth logic) const token = localStorage.getItem('auth_token'); if (token) { // Clone the request and add the token header const authReq = request.clone({ headers: request.headers.set('Authorization', `Bearer ${token}`) }); return next.handle(authReq); } return next.handle(request); } } -
Provide the interceptor in
AppModule:// src/app/app.module.ts import { HTTP_INTERCEPTORS } from '@angular/common/http'; import { AuthInterceptor } from './core/auth.interceptor'; @NgModule({ // ... (previous code) providers: [ { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true } ] }) export class AppModule { }
The multi: true flag allows multiple interceptors (Angular executes them in the order they’re provided).
Logging Interceptor
Create a logging interceptor to log requests and responses:
ng generate interceptor core/logging
Update the interceptor:
// src/app/core/logging.interceptor.ts
import { Injectable } from '@angular/core';
import {
HttpRequest,
HttpHandler,
HttpEvent,
HttpInterceptor,
HttpResponse
} from '@angular/common/http';
import { Observable, tap } from 'rxjs';
@Injectable()
export class LoggingInterceptor implements HttpInterceptor {
constructor() {}
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
const start = Date.now();
return next.handle(request).pipe(
tap({
next: (event) => {
if (event instanceof HttpResponse) {
const duration = Date.now() - start;
console.log(`Request to ${request.url} took ${duration}ms`);
console.log('Response:', event.body);
}
},
error: (err) => console.error('Request failed:', err)
})
);
}
}
Add it to AppModule’s providers:
// src/app/app.module.ts
providers: [
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
{ provide: HTTP_INTERCEPTORS, useClass: LoggingInterceptor, multi: true } // Add this
]
Type Safety with Interfaces
Using any for API responses loses TypeScript’s type safety. Define interfaces to enforce data shapes.
Create an interface for a Post:
ng generate interface models/post
Update src/app/models/post.ts:
// src/app/models/post.ts
export interface Post {
id: number;
title: string;
body: string;
userId: number;
}
Update ApiService to use Post instead of any:
// src/app/core/api.service.ts
import { Post } from '../models/post'; // Import the interface
// ... (previous code)
getPosts(): Observable<Post[]> {
return this.http.get<Post[]>(`${this.baseUrl}/posts`).pipe(
catchError(this.handleError)
);
}
getPostById(id: number): Observable<Post> {
return this.http.get<Post>(`${this.baseUrl}/posts/${id}`).pipe(
catchError(this.handleError)
);
}
// Update createPost, updatePost similarly...
Now TypeScript will enforce that Post objects have id, title, body, and userId—catching errors early!
Adding Loading Indicators
Users expect feedback when data is loading. Let’s create a loading service and component.
1. Loading Service
Generate a LoadingService to manage loading state:
ng generate service core/loading
Update the service:
// src/app/core/loading.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class LoadingService {
private isLoadingSubject = new BehaviorSubject<boolean>(false);
isLoading$ = this.isLoadingSubject.asObservable();
show(): void {
this.isLoadingSubject.next(true);
}
hide(): void {
this.isLoadingSubject.next(false);
}
}
2. Loading Component
Generate a LoadingComponent to display a spinner:
ng generate component shared/loading
Update the template:
<!-- src/app/shared/loading/loading.component.html -->
<div *ngIf="isLoading$ | async" class="loading-spinner">
<div class="spinner"></div>
</div>
Add styles:
/* src/app/shared/loading/loading.component.css */
.loading-spinner {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.spinner {
width: 50px;
height: 50px;
border: 5px solid #f3f3f3;
border-top: 5px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
Update the component class:
// src/app/shared/loading/loading.component.ts
import { Component } from '@angular/core';
import { LoadingService } from '../../core/loading.service';
import { Observable } from 'rxjs';
@Component({
selector: 'app-loading',
templateUrl: './loading.component.html',
styleUrls: ['./loading.component.css']
})
export class LoadingComponent {
isLoading$: Observable<boolean>;
constructor(private loadingService: LoadingService) {
this.isLoading$ = this.loadingService.isLoading$;
}
}
3. Use Loading Service in API Calls
Update ApiService to trigger loading state:
// src/app/core/api.service.ts
import { LoadingService } from './loading.service';
// ... (previous code)
constructor(private http: HttpClient, private loadingService: LoadingService) { }
getPosts(): Observable<Post[]> {
this.loadingService.show(); // Show loading
return this.http.get<Post[]>(`${this.baseUrl}/posts`).pipe(
catchError(this.handleError),
tap(() => this.loadingService.hide()) // Hide loading on success/error
);
}
Add <app-loading></app-loading> to AppComponent’s template to see the spinner when fetching posts!
Testing the API Client
Angular provides HttpClientTestingModule to test HTTP requests without hitting a real server. Let’s write a simple test for ApiService.
First, install dependencies (if not already installed):
npm install --save-dev @angular/common/http/testing
Create a test file src/app/core/api.service.spec.ts:
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { ApiService } from './api.service';
import { Post } from '../models/post';
describe('ApiService', () => {
let service: ApiService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [ApiService]
});
service = TestBed.inject(ApiService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify(); // Ensure no outstanding requests
});
it('should fetch posts', () => {
const mockPosts: Post[] = [
{ id: 1, title: 'Test Post', body: 'Test Body', userId: 1 }
];
service.getPosts().subscribe(posts => {
expect(posts).toEqual(mockPosts);
});
const req = httpMock.expectOne('https://jsonplaceholder.typicode.com/posts');
expect(req.request.method).toBe('GET');
req.flush(mockPosts);
});
});
Run tests with ng test—the test should pass!
Best Practices
-
Use Environment Variables: Store API URLs in
environment.tsfor easy configuration across environments:// src/environments/environment.ts export const environment = { production: false, apiUrl: 'https://jsonplaceholder.typicode.com' };Update
ApiServiceto useenvironment.apiUrl. -
Avoid Subscribing in Components: Use the
asyncpipe in templates to auto-subscribe/unsubscribe:<div *ngFor="let post of posts$ | async">...</div>posts$: Observable<Post[]> = this.apiService.getPosts(); -
Handle CORS: If your API is on a different domain, configure CORS on the backend or use Angular’s proxy configuration for development.
-
Centralize API Logic: Keep all API calls in core services, not components.
-
Use Interceptors Sparingly: Overusing interceptors can make debugging harder. Use them for cross-cutting concerns (auth, logging).
Conclusion
Building a RESTful API client in Angular involves leveraging HttpClientModule for requests, services for organization, interceptors for global behavior, and TypeScript for type safety. By following the steps in this guide, you’ve created a robust client with error handling, loading indicators, and testability.
Key takeaways:
- Use
HttpClientand Observables for reactive data fetching. - Centralize API logic in services with dependency injection.
- Handle errors and loading states for a smooth user experience.
- Test services with
HttpClientTestingModule. - Follow best practices like environment variables and the
asyncpipe.