Angular API Client Core
angular-api-client-core is a small, backend-agnostic Angular library for building typed feature API services without repeating HttpClient, base URL handling, or request setup in every service.
The library provides a reusable BaseApiService with protected GET, POST, PUT, PATCH, and DELETE methods. Your application extends that base class and exposes only the domain operations it actually supports.
It does not impose a backend response envelope, authentication provider, state-management library, or business-domain model. Those concerns remain in the consuming application.
Table of contents
- Why use this library?
- Requirements
- Installation
- Quick start
- HTTP method examples
- Request options
- Authentication
- Backend response envelopes
- Error handling
- Testing a feature API service
- URL and security rules
- Architecture guidance
- Troubleshooting
- Building and packaging from source
- Public API
- License
Why use this library?
Without a shared base class, Angular feature services often repeat the same infrastructure:
this.http.get(`${environment.apiBaseUrl}/users`);
this.http.post(`${environment.apiBaseUrl}/users`, request);
That repetition creates several problems:
- Base URL normalization is implemented more than once.
- Feature services become coupled to environment-file layouts.
- Request options and generic response types are inconsistent.
- Backend envelope mapping gets copied into every endpoint method.
- Refactoring authentication, error handling, or request behavior becomes harder.
With this library, a feature service contains only its endpoint paths and DTO contracts:
@Injectable({ providedIn: 'root' })
export class UsersApiService extends BaseApiService {
getUsers(): Observable<readonly UserDto[]> {
return this.get<readonly UserDto[]>('users');
}
}
What the library provides
- A centrally configured API base URL.
- Safe joining of the base URL and relative endpoint paths.
- Typed JSON
GET,POST,PUT,PATCH, andDELETEmethods. - Support for headers, query parameters,
HttpContext, timeouts, credentials, and transfer cache options. - Optional request bodies for APIs that require a
DELETEbody. - Full compatibility with Angular HTTP interceptors.
- A narrow public API that can be moved between applications.
What the library deliberately does not provide
- Login, logout, token storage, or token refresh.
- A dependency on Keycloak, Auth0, Firebase, or another identity provider.
- A required backend response format such as
{ data, success }. - Global toast, router, form, NgRx, or business-domain behavior.
- Automatic retry rules that could repeat non-idempotent requests.
- File upload/download event abstractions.
Keeping these concerns outside the package is what makes it reusable.
Requirements
The current package build targets:
- Angular
^21.2.0 @angular/commonand@angular/corefrom the same Angular release line- RxJS
^7.8.0 tslib ^2.3.0- An application configured with Angular
HttpClient
The package uses Angular partial compilation and is intended for Angular applications. It is not a framework-independent Fetch or Axios client.
Installation
From npm
After the package is published:
npm install angular-api-client-core
Do not install a second Angular version specifically for this package. The package declares Angular as a peer dependency and must use the consuming application's Angular runtime.
From a local package tarball
This is useful before the first public npm release:
npm run build
cd dist/angular-api-client-core
npm pack
Install the generated .tgz file in another Angular project:
npm install /absolute/path/to/angular-api-client-core-1.0.0.tgz
From the same Angular workspace
Build the library before starting or building the application:
npm run build
The consuming application can then import from the package name:
import { BaseApiService, provideApiClient } from 'angular-api-client-core';
Avoid importing internal paths such as angular-api-client-core/src/lib/.... Only imports from the package root are part of the supported public API.
Quick start
1. Register Angular HttpClient and the API base URL
For a standalone Angular application, add both providers to app.config.ts:
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideApiClient } from 'angular-api-client-core';
import { authTokenInterceptor } from './core/interceptors/auth-token.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(withInterceptors([authTokenInterceptor])),
provideApiClient({
baseUrl: 'https://api.example.com/v1',
}),
],
};
If authentication is not required, use provideHttpClient() without withInterceptors(...).
The base URL:
- must not be empty;
- may contain a path such as
/api/v1; - may contain trailing slashes, which are removed during setup;
- is stored as an immutable configuration object.
2. Define request and response DTOs
export interface UserDto {
readonly id: string;
readonly displayName: string;
readonly active: boolean;
}
export interface CreateUserRequest {
readonly displayName: string;
}
export interface UpdateUserRequest {
readonly displayName: string;
readonly active: boolean;
}
Keep these DTOs in the consuming feature. The library does not need to know your business models.
3. Extend BaseApiService
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { BaseApiService } from 'angular-api-client-core';
import { CreateUserRequest, UpdateUserRequest, UserDto } from './user.models';
@Injectable({ providedIn: 'root' })
export class UsersApiService extends BaseApiService {
getUsers(): Observable<readonly UserDto[]> {
return this.get<readonly UserDto[]>('users');
}
getUser(userId: string): Observable<UserDto> {
const encodedUserId = encodeURIComponent(userId);
return this.get<UserDto>(`users/${encodedUserId}`);
}
createUser(request: CreateUserRequest): Observable<UserDto> {
return this.post<CreateUserRequest, UserDto>('users', request);
}
updateUser(userId: string, request: UpdateUserRequest): Observable<UserDto> {
const encodedUserId = encodeURIComponent(userId);
return this.put<UpdateUserRequest, UserDto>(`users/${encodedUserId}`, request);
}
deleteUser(userId: string): Observable<void> {
const encodedUserId = encodeURIComponent(userId);
return this.delete<void>(`users/${encodedUserId}`);
}
}
The inherited HTTP methods are protected. Components cannot inject BaseApiService and issue arbitrary requests. They use explicit methods such as getUsers() or createUser() from your feature service.
4. Consume the feature service
import { Component, inject } from '@angular/core';
import { UsersApiService } from './users-api.service';
@Component({
selector: 'app-users-page',
template: `<!-- Render loading, error, empty, and success states here. -->`,
})
export class UsersPage {
private readonly usersApi = inject(UsersApiService);
loadUsers(): void {
this.usersApi.getUsers().subscribe({
next: (users) => console.log(users),
error: (error) => console.error(error),
});
}
}
For larger applications, call the feature API service from a facade, store effect, or other application layer instead of directly from a component.
HTTP method examples
All methods return Observable<TResponse> and expect JSON responses.
GET
getUsers(page: number): Observable<readonly UserDto[]> {
return this.get<readonly UserDto[]>('users', {
params: { page, active: true },
});
}
POST
The first generic type is the request body; the second is the response body:
createUser(request: CreateUserRequest): Observable<UserDto> {
return this.post<CreateUserRequest, UserDto>('users', request);
}
PUT
replaceUser(userId: string, request: UpdateUserRequest): Observable<UserDto> {
return this.put<UpdateUserRequest, UserDto>(
`users/${encodeURIComponent(userId)}`,
request,
);
}
PATCH
setUserStatus(userId: string, active: boolean): Observable<UserDto> {
return this.patch<{ readonly active: boolean }, UserDto>(
`users/${encodeURIComponent(userId)}`,
{ active },
);
}
DELETE
deleteUser(userId: string): Observable<void> {
return this.delete<void>(`users/${encodeURIComponent(userId)}`);
}
If a backend explicitly requires a DELETE body:
deleteUser(userId: string, reason: string): Observable<DeleteResultDto> {
return this.delete<DeleteResultDto>(`users/${encodeURIComponent(userId)}`, {
body: { reason },
});
}
Only send a DELETE body when the backend contract requires it. Some servers and intermediaries do not support DELETE bodies consistently.
Request options
Every HTTP method accepts an optional request-options object. DELETE additionally accepts body.
| Option | Type | Purpose |
|---|---|---|
headers |
HttpHeaders or a string record |
Request-specific headers |
context |
HttpContext |
Metadata shared with Angular interceptors |
params |
HttpParams or a primitive-value record |
URL query parameters |
reportProgress |
boolean |
Enables Angular request progress preparation |
withCredentials |
boolean |
Sends credentials for compatible cross-origin requests |
credentials |
RequestCredentials |
Fetch-backend credential policy |
transferCache |
boolean or include-header config |
Angular SSR transfer-cache behavior |
timeout |
number |
Angular request timeout in milliseconds |
Query parameters with a plain object
return this.get<readonly UserDto[]>('users', {
params: {
page: 2,
pageSize: 25,
active: true,
role: ['admin', 'editor'],
},
});
Supported plain-object query values are string, number, boolean, or arrays of those primitive values.
Do not put undefined, null, objects, or dates directly in the parameter record. Convert dates to the format required by your backend and omit optional values explicitly.
Query parameters with HttpParams
import { HttpParams } from '@angular/common/http';
let params = new HttpParams();
params = params.set('FromUtc', fromDate.toISOString());
params = params.set('PageNumber', pageNumber);
return this.get<SearchResultDto>('search', { params });
Remember that HttpParams is immutable. Assign the result of every set, append, or delete call.
Request-specific headers
return this.get<UserDto>('users/current', {
headers: {
'X-Correlation-Id': correlationId,
},
});
Use an interceptor for headers required by most requests. Use request options for headers specific to one operation.
Authentication
This library does not read, store, create, or refresh access tokens. Authentication belongs to the consuming application and should normally be implemented with an Angular HTTP interceptor.
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { SessionService } from './session.service';
export const authTokenInterceptor: HttpInterceptorFn = (request, next) => {
const accessToken = inject(SessionService).getAccessToken();
if (!accessToken) {
return next(request);
}
return next(
request.clone({
setHeaders: {
Authorization: `Bearer ${accessToken}`,
},
}),
);
};
Register the interceptor when providing Angular HttpClient:
provideHttpClient(withInterceptors([authTokenInterceptor]));
Because BaseApiService uses Angular's injected HttpClient, all registered interceptors continue to run normally.
Backend response envelopes
Many APIs return an envelope instead of the domain payload directly:
export interface ApiResponse<T> {
readonly success: boolean;
readonly data: T;
readonly message?: string | null;
}
Do not add your application-specific envelope to the general library. Create an application adapter once and let feature services extend it:
import { Observable, map } from 'rxjs';
import { ApiRequestOptions, BaseApiService } from 'angular-api-client-core';
export abstract class ApplicationApiService extends BaseApiService {
protected getData<TResponse>(
path: string,
options: ApiRequestOptions = {},
): Observable<TResponse> {
return this.get<ApiResponse<TResponse>>(path, options).pipe(map((response) => response.data));
}
protected postData<TRequest, TResponse>(
path: string,
body: TRequest,
options: ApiRequestOptions = {},
): Observable<TResponse> {
return this.post<TRequest, ApiResponse<TResponse>>(path, body, options).pipe(
map((response) => response.data),
);
}
}
Feature services then use the application-specific adapter:
@Injectable({ providedIn: 'root' })
export class UsersApiService extends ApplicationApiService {
getUsers(): Observable<readonly UserDto[]> {
return this.getData<readonly UserDto[]>('users');
}
}
This keeps the reusable package backend-agnostic while removing envelope mapping duplication from the application.
Error handling
HTTP errors remain standard Angular HttpErrorResponse values. Handle cross-cutting rules in an interceptor:
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { catchError, throwError } from 'rxjs';
export const apiErrorInterceptor: HttpInterceptorFn = (request, next) =>
next(request).pipe(
catchError((error: HttpErrorResponse) => {
// Map or report the error according to your application's policy.
return throwError(() => error);
}),
);
Recommended responsibility split:
- Interceptor: authentication failures, common error normalization, correlation, and logging.
- Feature service: endpoint path and request/response DTO types.
- Facade/effect: user intent, loading state, success/failure state, navigation, and notifications.
- Component: rendering and user interaction.
The library does not retry requests automatically. Retrying POST, PATCH, or DELETE without an idempotency strategy can duplicate mutations.
Testing a feature API service
Use Angular's HTTP testing providers together with provideApiClient:
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { firstValueFrom } from 'rxjs';
describe('UsersApiService', () => {
let http: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
provideApiClient({ baseUrl: 'https://api.example.test/v1/' }),
UsersApiService,
],
});
http = TestBed.inject(HttpTestingController);
});
afterEach(() => {
http.verify();
});
it('loads users with a normalized URL', async () => {
const result = firstValueFrom(TestBed.inject(UsersApiService).getUsers());
const request = http.expectOne('https://api.example.test/v1/users');
expect(request.request.method).toBe('GET');
request.flush([{ id: '1', displayName: 'Ada', active: true }]);
await expect(result).resolves.toEqual([{ id: '1', displayName: 'Ada', active: true }]);
});
});
Always register provideHttpClient() before provideHttpClientTesting() so the testing backend can replace the real backend correctly.
Useful assertions include:
- exact URL after base/path normalization;
- HTTP method;
- query parameter names and values;
- request headers;
- request body;
- response type/envelope mapping;
- absence of ownership or user identifiers that the backend derives from authentication.
URL and security rules
Endpoint paths passed to inherited HTTP methods must be relative to the configured base URL.
Accepted examples:
users
/users
users/42/notes
Rejected examples:
https://other.example.com/users
//other.example.com/users
../admin
users\42
These checks help prevent a feature service from accidentally bypassing the configured API boundary.
Important limitations:
- The library does not validate business-level route identifiers.
- Always call
encodeURIComponenton dynamic route segments. - Do not concatenate untrusted text into a path without encoding it.
- Do not place access tokens, passwords, or API keys in query parameters.
- Base URL validation ensures a non-empty value; the consuming application remains responsible for supplying the correct trusted origin.
Architecture guidance
A scalable consuming application can use this dependency direction:
Component/Page
↓
Facade or Store Effect
↓
Feature API Service
↓
Application API Adapter (optional response-envelope mapping)
↓
BaseApiService
↓
Angular HttpClient + Interceptors
Recommended rules:
- Create one feature API service per business capability instead of one giant API service.
- Keep
BaseApiServicemethods protected. - Keep DTOs close to the feature that owns them.
- Put authentication and global error behavior in interceptors.
- Put backend-envelope mapping in a consuming-application adapter.
- Do not add application imports to the reusable package.
- Verify endpoint names and DTOs against the backend's OpenAPI contract.
Troubleshooting
NullInjectorError: No provider for HttpClient
Register Angular HttpClient during application bootstrap:
provideHttpClient();
NullInjectorError for API_CLIENT_CONFIG
Register the library configuration:
provideApiClient({ baseUrl: 'https://api.example.com/v1' });
API client baseUrl değeri boş olamaz.
The configured base URL is empty or contains only whitespace. Ensure the environment value exists before calling provideApiClient.
BaseApiService yalnızca relative endpoint path kabul eder.
An inherited method received an absolute or protocol-relative URL. Configure the origin once in provideApiClient and pass only a relative endpoint path to the feature service method.
Endpoint path base URL sınırının dışına çıkamaz.
The endpoint contains a parent traversal segment (..) or a backslash. Use a normal forward-slash relative endpoint path.
Authorization header is missing
The library does not create tokens. Confirm that:
- an authentication interceptor is registered with
provideHttpClient; - the interceptor has access to a valid token;
- the interceptor does not exclude the target API URL;
- the request is sent through Angular
HttpClientrather than another client.
The API returns an envelope instead of the expected DTO
The generic type describes the real HTTP response body. If the backend returns ApiResponse<UserDto>, request that type and map it in an application adapter as shown in Backend response envelopes.
Query parameters include unwanted values
Remove undefined, null, and empty values before creating the options object. The library forwards supported query values; it does not apply business-specific filtering rules.
Multiple API base URLs are required
The current public provider represents one API base URL per Angular injector. Do not switch a global base URL at request time. Use an explicit application-level client boundary or a child injector configuration for a second backend, and keep each feature service tied to the intended API.
Building and packaging from source
From the current repository workspace root:
npm install
npm run test:ci
npm run build
The distributable Angular package is generated at:
dist/angular-api-client-core
Inspect the package before publishing:
cd dist/angular-api-client-core
npm pack --dry-run
Create a local tarball for installation testing:
npm pack
Publish only from the built dist/angular-api-client-core directory, not from the TypeScript source directory:
cd dist/angular-api-client-core
npm publish --access public
Before a public release:
- choose and verify the final package name;
- update the semantic version;
- confirm Angular peer dependency compatibility;
- run all tests and the production package build;
- inspect
npm pack --dry-runoutput; - install the tarball in a clean Angular application;
- add the real GitHub
repository,homepage, andbugsURLs topackage.json; - publish with an npm account that owns the chosen package name.
Public API
Import these symbols from angular-api-client-core:
| Symbol | Purpose |
|---|---|
BaseApiService |
Abstract base class with protected HTTP methods |
provideApiClient |
Registers and normalizes the API base URL |
ApiClientConfig |
Configuration contract containing baseUrl |
API_CLIENT_CONFIG |
Advanced access to the normalized injection token |
ApiRequestOptions |
Shared JSON request options |
ApiDeleteRequestOptions |
Request options with optional DELETE body |
ApiQueryParams |
HttpParams or a primitive query-value record |
ApiQueryValue |
Supported query primitive type |
Protected method signatures
get<TResponse>(path, options?): Observable<TResponse>
post<TRequest, TResponse>(path, body, options?): Observable<TResponse>
put<TRequest, TResponse>(path, body, options?): Observable<TResponse>
patch<TRequest, TResponse>(path, body, options?): Observable<TResponse>
delete<TResponse>(path, options?): Observable<TResponse>
License
MIT. See LICENSE.