HttpClient and API Integration
Before you start
You need: RxJS (Article 08) and an API — Track 10, or a mock.
Time: about 50 minutes, plus the practice.
Learning objective
Call a REST API from Angular handling every outcome — loading, success, empty, error and cancellation — and diagnose a failed request from the Network tab.
Topics
- Setting up
HttpClient - GET, POST, PUT, DELETE
- Query parameters and headers
- Typed responses
- Handling every status code
- The four states
- Debounce, cancellation and races
- File upload and download
- CORS
- Diagnosing a failed call
Setup
// app.config.ts
import { provideHttpClient, withInterceptors, withFetch } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withFetch(),
withInterceptors([authInterceptor, errorInterceptor])
)
]
};
withFetch() uses the Fetch API rather than XMLHttpRequest — better streaming support and the direction Angular is moving.
Missing provideHttpClient() gives NullInjectorError: No provider for HttpClient, which is the first error everyone hits.
The verbs
@Injectable({ providedIn: 'root' })
export class StudentService {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.apiUrl}/api/students`;
search(query: StudentQuery): Observable<PagedResult<Student>> {
let params = new HttpParams()
.set('page', query.page)
.set('pageSize', query.pageSize);
if (query.term) {
params = params.set('term', query.term);
}
if (query.className) {
params = params.set('className', query.className);
}
return this.http.get<PagedResult<Student>>(this.baseUrl, { params });
}
getByPublicId(publicId: string): Observable<Student> {
return this.http.get<Student>(`${this.baseUrl}/${publicId}`);
}
create(request: StudentCreateRequest): Observable<Student> {
return this.http.post<Student>(this.baseUrl, request);
}
update(publicId: string, request: StudentUpdateRequest): Observable<void> {
return this.http.put<void>(`${this.baseUrl}/${publicId}`, request);
}
delete(publicId: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/${publicId}`);
}
}
Three things Angular does for you that fetch does not:
- JSON is serialised and parsed automatically, and
Content-Type: application/jsonis set on any request with an object body. - Non-2xx statuses produce an error, delivered to the
errorcallback. Noresponse.okcheck. - The request is cancellable by unsubscribing.
HttpParams is immutable. params.set(...) returns a new instance:
// Wrong — the result is discarded
params.set('term', term);
// Right
params = params.set('term', term);
That silently drops the parameter, and the API returns unfiltered data.
HttpParams also URL-encodes values, so a search for 10th & A works. Building the query string by concatenation does not.
Headers
const headers = new HttpHeaders()
.set('X-Correlation-Id', crypto.randomUUID())
.set('Accept', 'application/json');
return this.http.get<Student[]>(this.baseUrl, { headers });
Do not set the Authorization header per call. An interceptor adds it once for every request — covered in the next article. Setting it by hand in twenty services means twenty places to change and one that gets forgotten.
HttpHeaders is immutable in the same way as HttpParams.
Typed responses
export interface Student {
publicId: string;
name: string;
rollNumber: string;
className: string;
section: string;
dateOfBirth: string; // ISO string, not Date
parentName: string;
parentPhone: string;
address: string | null;
}
export interface PagedResult<T> {
items: T[];
totalCount: number;
page: number;
pageSize: number;
}
this.http.get<Student>() is a promise to the compiler, not a check. Nothing validates the response shape. A backend rename produces undefined at the point of use, far from the cause.
Dates arrive as strings. student.dateOfBirth.getFullYear() fails because it is a string, and TypeScript does not catch it if the interface says Date. Type it as string and convert where needed:
map(student => ({ ...student, dateOfBirth: new Date(student.dateOfBirth) }))
For data that matters, validate at the boundary — a runtime schema library derives the type from the schema, so there is one source of truth.
Handling status codes
import { HttpErrorResponse } from '@angular/common/http';
this.studentService.create(request).subscribe({
next: created => this.router.navigate(['/students', created.publicId]),
error: (err: HttpErrorResponse) => {
if (err.status === 0) {
this.error.set('Cannot reach the server. Check your connection.');
return;
}
if (err.status === 400 && err.error?.errors) {
this.applyFieldErrors(err.error.errors);
return;
}
if (err.status === 409) {
this.error.set('This roll number is already in use.');
return;
}
if (err.status >= 500) {
this.error.set('The server is not responding. Please try again shortly.');
return;
}
this.error.set('Could not save. Please try again.');
}
});
status | Meaning | Show the user |
|---|---|---|
0 | Network failure, CORS block, or cancelled | "Cannot reach the server" |
| 400 | Validation failed | Field-level messages from err.error.errors |
| 401 | Not authenticated | Redirect to login |
| 403 | Not permitted | "You do not have permission" |
| 404 | Not found | "This student no longer exists" |
| 409 | Conflict | The specific conflict |
| 415 | Wrong content type | A bug in your code, not the user's problem |
| 500+ | Server failure | "Please try again shortly" |
status === 0 is the one people misread. It is not a server response — it means the request never completed: no network, a CORS block, or the request was cancelled. Reporting it as "server error" sends people looking in the wrong place.
ASP.NET Core returns a ValidationProblemDetails body on 400:
{
"title": "One or more validation errors occurred.",
"status": 400,
"traceId": "00-8a3c…-01",
"errors": {
"RollNumber": ["Format: NCA-2024-0012"],
"ParentPhone": ["Enter a valid 10-digit mobile number."]
}
}
private applyFieldErrors(errors: Record<string, string[]>): void {
for (const [field, messages] of Object.entries(errors)) {
const key = field.charAt(0).toLowerCase() + field.slice(1);
const control = this.form.get(key);
if (control && messages[0]) {
control.setErrors({ server: messages[0] });
control.markAsTouched();
}
}
}
Mapping those to controls gives field-level messages rather than one generic banner. The PascalCase-to-camelCase conversion is what makes the keys match.
Show the traceId on a 500. The user quotes it in a support ticket and someone finds the exact request in the logs.
The four states
Every request has four outcomes. Most student projects handle one.
type ListState =
| { status: 'loading' }
| { status: 'success'; result: PagedResult<Student> }
| { status: 'empty'; term: string }
| { status: 'error'; message: string };
export class StudentListComponent {
private readonly studentService = inject(StudentService);
readonly state = signal<ListState>({ status: 'loading' });
readonly searchTerm = signal('');
load(term: string, page = 1): void {
this.state.set({ status: 'loading' });
this.studentService.search({ term, page, pageSize: 20 }).subscribe({
next: result => this.state.set(
result.items.length === 0
? { status: 'empty', term }
: { status: 'success', result }),
error: (err: HttpErrorResponse) => this.state.set({
status: 'error',
message: err.status >= 500
? 'The server is not responding. Please try again shortly.'
: 'Could not load students.'
})
});
}
}
<p role="status" aria-live="polite" class="visually-hidden">{{ statusMessage() }}</p>
@switch (state().status) {
@case ('loading') {
<p class="muted">Loading students…</p>
}
@case ('empty') {
<p class="muted">No students match “{{ $any(state()).term }}”.</p>
}
@case ('error') {
<p class="error" role="alert">{{ $any(state()).message }}</p>
<button type="button" (click)="load(searchTerm())">Try again</button>
}
@case ('success') {
<app-student-table [result]="$any(state()).result" />
}
}
A discriminated union means the compiler enforces that every state is handled.
Distinguish empty from error. "No students match your search" and "Could not load students" are different messages with different next actions. A blank screen for both is indistinguishable from a bug.
aria-live="polite" announces state changes to a screen reader. A visual-only spinner tells them nothing.
Debounce, cancellation and races
export class StudentListComponent {
private readonly studentService = inject(StudentService);
readonly searchTerm = signal('');
readonly page = signal(1);
readonly result = toSignal(
combineLatest([
toObservable(this.searchTerm).pipe(debounceTime(300), distinctUntilChanged()),
toObservable(this.page)
]).pipe(
switchMap(([term, page]) =>
this.studentService.search({ term, page, pageSize: 20 }).pipe(
catchError(() => of({ items: [], totalCount: 0, page, pageSize: 20 }))
))
),
{ initialValue: { items: [], totalCount: 0, page: 1, pageSize: 20 } });
}
Three properties, all necessary:
debounceTime(300)— typing "Ravi" fires one request, not four.distinctUntilChanged()— typing and deleting a character does not refire.switchMap— each new term cancels the previous request, so an earlier slow response cannot overwrite a later fast one.
Without switchMap, throttle to Slow 3G and type quickly: the list ends up showing results for a prefix of what was typed. It looks like a caching bug and is a race.
catchError sits inside switchMap. On the outer stream, one failure terminates it and search stops working until the page is reloaded.
Retry and timeout
import { retry, timeout } from 'rxjs';
this.http.get<Student[]>(this.baseUrl).pipe(
timeout(10000),
retry({
count: 2,
delay: (error: HttpErrorResponse, retryCount) =>
error.status >= 500 || error.status === 0
? timer(retryCount * 1000)
: throwError(() => error)
})
);
Retry only transient failures — 5xx and network errors. Retrying a 400 or 404 fails identically and delays the real message.
Never retry a non-idempotent POST without an idempotency key; two retries can create three students.
File upload and download
upload(publicId: string, file: File): Observable<HttpEvent<void>> {
const formData = new FormData();
formData.append('photo', file, file.name);
return this.http.post<void>(`${this.baseUrl}/${publicId}/photo`, formData, {
reportProgress: true,
observe: 'events'
});
}
this.studentService.upload(publicId, file).subscribe(event => {
if (event.type === HttpEventType.UploadProgress && event.total) {
this.progress.set(Math.round(100 * event.loaded / event.total));
} else if (event.type === HttpEventType.Response) {
this.progress.set(100);
}
});
Do not set Content-Type with FormData. The browser sets multipart/form-data including the boundary; setting it yourself omits the boundary and the server cannot parse the body.
downloadReceipt(paymentId: number): Observable<Blob> {
return this.http.get(`${environment.apiUrl}/api/payments/${paymentId}/receipt`, {
responseType: 'blob'
});
}
this.feeService.downloadReceipt(id).subscribe(blob => {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `receipt-${id}.pdf`;
link.click();
URL.revokeObjectURL(url);
});
URL.revokeObjectURL releases the memory. Omitting it leaks a blob per download.
responseType: 'blob' also means errors arrive as a Blob, not JSON — reading the message needs await err.error.text().
Environments
// environments/environment.ts — production
export const environment = { production: true, apiUrl: 'https://api.nexcoding.in' };
// environments/environment.development.ts
export const environment = { production: false, apiUrl: 'https://localhost:7099' };
Always import the base environment; the build swaps the file.
Environment files ship to the browser. They hold URLs and feature flags — never an API key or a secret. There is no such thing as a client-side secret; ng build puts the value in a file anyone can read.
For local development against a different-origin API, a proxy avoids CORS entirely:
// proxy.conf.json
{
"/api": {
"target": "https://localhost:7099",
"secure": false,
"changeOrigin": true
}
}
ng serve --proxy-config proxy.conf.json
Requests to /api/students are forwarded by the dev server, so the browser sees a same-origin request. This only works in ng serve — production needs real CORS, which is why the proxy sometimes hides a problem until deployment.
CORS
Access to fetch at 'https://api.nexcoding.in/api/students'
from origin 'https://portal.nexcoding.in' has been blocked by CORS policy.
No Angular change fixes this. No HttpClient option, no header, no interceptor.
The request usually reached the server and succeeded — the server returned 200, and the browser then refused to hand the response to your code because Access-Control-Allow-Origin was missing. That is why the same call works in Postman, which is not a browser.
// The fix is in Program.cs, not in Angular
builder.Services.AddCors(options =>
{
options.AddPolicy("SchoolPortal", policy =>
policy.WithOrigins("https://portal.nexcoding.in")
.AllowAnyHeader()
.AllowAnyMethod());
});
app.UseRouting();
app.UseCors("SchoolPortal");
app.UseAuthentication();
An OPTIONS request returning 404 or 405 in the Network tab means CORS middleware is not wired up. One returning 401 means UseCors is after UseAuthentication — the preflight carries no token.
A CORS block surfaces in Angular as status: 0.
Diagnosing a failed call
Open the Network tab, filter to Fetch/XHR, then reproduce.
| What you see | Meaning |
|---|---|
| No request at all | Never subscribed, or the code never ran |
(failed) or (canceled) | Network failure, CORS block, or switchMap cancelled it |
| 401 | Token missing or expired |
| 403 | Wrong role |
| 415 | Missing Content-Type — a bug in the caller |
| 400 | Read err.error.errors for the fields |
| 500 | Server-side; the browser cannot tell you why |
| 200 but nothing renders | The response arrived; the rendering code is wrong |
Copy as cURL, then replay in Postman. Works there but fails in the browser → a frontend problem: CORS, a header, or the token. Fails in both → a backend problem.
A copied cURL contains your live Authorization header. Redact it before pasting it into a ticket.
Errors you will hit
| What you see | Cause | Fix |
|---|---|---|
NullInjectorError: No provider for HttpClient | provideHttpClient() missing | Add it |
| CORS error in the browser, fine in Postman | CORS is browser-enforced, server-configured | Fix it on the API |
Response fields are undefined | Casing mismatch with the API's JSON | Match exactly |
| The request never fires | Not subscribed | Subscribe or use async |
| 401 on every call | Token not attached | Use an interceptor |
Errors show as [object Object] | Rendered the whole HttpErrorResponse | Read error.error or error.status |
API URLs belong in src/environments, not in the service. Anything in the built bundle is visible to the user, so it is configuration, never a secret.
Common mistakes
- Missing
provideHttpClient() - Discarding the result of
params.set(...) - Setting
Authorizationper call instead of in an interceptor - Trusting
http.get<T>()as validation - Typing a date field as
Datewhen it arrives as a string - Treating
status: 0as a server error - Ignoring the
errorsobject on a 400 - Only handling success — no loading, empty or error state
- Hiding the spinner only on success
- No debounce or
switchMapon a live search catchErroroutsideswitchMap- Retrying 4xx, or retrying a POST
- Setting
Content-TypewithFormData - No
revokeObjectURLafter a download - A secret in an environment file
- Trying to fix CORS in Angular
Practice
The course exercises are call a GET/POST API and handle API errors.
- Build
StudentServicewith all five verbs. Confirm each in the Network tab. - Write
params.set('term', term)without reassigning. Confirm the filter is silently ignored. - Search for
10th & AusingHttpParams, then by string concatenation. Compare the request URLs. - Type a date field as
Dateand call.getFullYear(). Record the runtime error. - Implement all four states with a discriminated union. Force each.
- Stop the API and trigger a request. Confirm
status: 0, and write the right message. - POST an invalid body and map
err.error.errorsto form controls. - Force a 500 and show the
traceId. Find the matching entry in the API log. - Build a live search with no debounce and no
switchMap. Throttle to Slow 3G, type quickly, and confirm out-of-order results. - Add
debounceTime,distinctUntilChangedandswitchMap. Confirm one result and cancelled requests. - Put
catchErroron the outer stream, force one failure, then search again. Confirm search is dead. Move it inside. - Add
retryfor 5xx only. Confirm a 400 is not retried. - Upload a file with progress. Set
Content-Typemanually and confirm the server cannot parse it. - Download a PDF as a blob. Omit
revokeObjectURL, download fifty times, and watch memory. - Configure
proxy.conf.jsonand call the API without CORS. Then build and servedist/and confirm the CORS error appears. - Copy a failing request as cURL and replay it in Postman.
Exercises 9 and 15 correspond to a visible race and a problem that only appears after deployment.
You can now
- Call an API handling loading, success, empty and error
- Recognise a CORS error and say where it is fixed
- Keep the API URL in environment configuration
- Prevent races between in-flight requests
- Turn an
HttpErrorResponseinto a useful message
Review questions
- What does
status: 0mean, and what does it not mean? - Why does
params.set(...)need its result assigned? - Why must
catchErrorsit insideswitchMapon a search stream? - Why does a dev-server proxy sometimes hide a CORS problem until deployment?