Today, I want to show you how we can create a REST API service abstraction in the client.
These are more refactor-safe, decoupled and can be e.g. unit tested.
Project Structure
Create a basic vue project and add a services
folder in src
src/
assets/
components/
composables/
plugins/
router/
services/ <-- This will hold our API services
shared/
store/
views/
App.vue
main.ts
The Abstraction
First, we create an abstract class AbstractApiService
in src/services/AbstractApiService.ts
.
This contains the axios instance and will serve as a wrapper. It contains helpful functions that handles errors and other stuff.
So we add a http: AxiosInstance
as a property, a constructor
and these helper functions.
import type { AxiosError, AxiosInstance, AxiosResponse } from 'axios';
import axios from 'axios';
export function isAxiosError(value: any): value is AxiosError {
return typeof value?.response === 'object';
}
export abstract class AbstractApiService {
protected readonly http: AxiosInstance;
protected constructor(
protected readonly path?: string,
protected readonly baseURL: string = import.meta.env.VITE_BACKEND ?? '/'
) {
if (path) {
baseURL += path;
}
this.http = axios.create({
baseURL,
// ... further stuff, e.g. `withCredentials: true`
});
this.http.defaults.headers.common['Accept'] = 'application/json;charset=UTF-8';
this.http.defaults.headers.common['Content-Type'] = 'application/json;charset=UTF-8';
}
protected createParams(record: Record<string, any>): URLSearchParams {
const params: URLSearchParams = new URLSearchParams();
for (const key in record) {
if (Object.prototype.hasOwnProperty.call(record, key)) {
const value: any = record[key];
if (value !== null && value !== undefined) {
params.append(key, value);
} else {
console.debug(`Param key '${key}' was null or undefined and will be ignored`);
}
}
}
return params;
}
protected handleResponse<T>(response: AxiosResponse<T>): T {
return response.data;
}
protected handleError(error: unknown): never {
if (error instanceof Error) {
if (isAxiosError(error)) {
if (error.response) {
console.log(error.response.data);
console.log(error.response.status);
console.log(error.response.headers);
throw error;
} else if (error.request) {
// The request was made but no response was received
// `error.request` is an instance of XMLHttpRequest in the browser
console.log(error.request);
throw new Error(error as any);
}
} else {
// Something happened in setting up the request that triggered an Error
console.log('Error', error.message);
throw new Error(error.message);
}
}
throw new Error(error as any);
}
}
Feel free to modify the constructor
to your own needs.
Creating a Concrete Service
Now we have e.g. a JobApiService
like so
import type { JobCreateModel, JobModel } from '@/shared/models/JobModel';
import { AbstractApiService } from './AbstractApiService';
class JobApiService extends AbstractApiService {
public constructor() {
super('/api/jobs');
}
public jobs(customer?: string, portal?: string): Promise<JobModel[]> {
return this.http
.get('', {
params: {
customer,
portal
}
})
.then(this.handleResponse.bind(this))
.catch(this.handleError.bind(this));
}
public job(id: number): Promise<JobModel> {
return this.http.get(`/${id}`)
.then(this.handleResponse.bind(this))
.catch(this.handleError.bind(this));
}
public startJob(job: JobCreateModel): Promise<void> {
return this.http.post('', job)
.then(this.handleResponse.bind(this))
.catch(this.handleError.bind(this));
}
public rerunJob(id: number): Promise<void> {
return this.http.post(`/rerun/${id}`)
.then(this.handleResponse.bind(this))
.catch(this.handleError.bind(this));
}
}
export const jobApiService: JobApiService = new JobApiService();
Note that we especially don't export the class itself! But we create an instance that can be reused/imported in other views.
const jobs = await jobApiService.jobs(customer, portal);
Further Extension for the Real World
A Service Cacher
My services request different domains (e.g. customer1.test.mydomain.com
and customer2.prod.mydomain.com
) or endpoints (e.g. /api/lang/en/groups
and /api/lang/de/groups
). But I kinda want to use singletons, so we fake the instances for the services and create them only once when they get first invoked.
So we build a ServiceCacher
in src/services/ServiceCacher.ts
import { AbstractApiService } from './AbstractApiService';
export class ServiceCacher<Service extends AbstractApiService> {
private readonly CACHE: Map<string, Service> = new Map<string, Service>();
public constructor(
private readonly serviceName: string,
private readonly serviceClass: new (baseUrl: string) => Service
) {}
public instance(baseUrl: string): Service {
if (this.CACHE.has(baseUrl)) {
return this.CACHE.get(baseUrl)!;
}
console.debug(`Creating new instance of ${this.serviceName} for baseUrl '${baseUrl}'`);
const instance: Service = new this.serviceClass(baseUrl);
this.CACHE.set(baseUrl, instance);
return instance;
}
}
Creating a Specialized Service Using the Service Cacher
At first, we have a sub abstraction for an API that is deployed for different customers.
// src/services/lps/AbstractLpsApiService.ts
import { AbstractApiService } from '../AbstractApiService';
export abstract class AbstractLpsApiService extends AbstractApiService {
protected constructor(path: string, lpsUrl: string) {
super(path, lpsUrl);
}
}
(lps = Landing Page Service)
// src/services/lps/SynonymGroupApiService.ts
import type { SynonymGroup } from '@/shared/models/entities/Synonym';
import type { Pageable, Sort } from '@/shared/requests/Pageable';
import type { Search } from '@/shared/requests/Search';
import type { Page } from '@/shared/responses/Page';
import { ServiceCacher } from '../ServiceCacher';
import { AbstractLpsApiService } from './LpsApiService';
export interface SynonymGroupFilter extends Search, Pageable, Sort {}
class SynonymGroupApiService extends AbstractLpsApiService {
public constructor(lpsPortalUrl: string) {
super('/synonym-groups', lpsPortalUrl);
}
public findAllPaged({ search }: SynonymGroupFilter = {}): Promise<Page<SynonymGroup>> {
return this.http.get('', { params: { search } })
.then(this.handleResponse.bind(this))
.catch(this.handleError.bind(this));
}
public findById(id: number): Promise<SynonymGroup> {
return this.http.get(`/${id}`)
.then(this.handleResponse.bind(this))
.catch(this.handleError.bind(this));
}
public create(content: SynonymGroup): Promise<SynonymGroup> {
return this.http.post('', content)
.then(this.handleResponse.bind(this))
.catch(this.handleError.bind(this));
}
public update(id: number, content: SynonymGroup): Promise<SynonymGroup> {
return this.http.put(`/${id}`, content)
.then(this.handleResponse.bind(this))
.catch(this.handleError.bind(this));
}
public delete(id: number): Promise<void> {
return this.http.delete(`/${id}`)
.then(this.handleResponse.bind(this))
.catch(this.handleError.bind(this));
}
}
const serviceCacher: ServiceCacher<SynonymGroupApiService> = new ServiceCacher<SynonymGroupApiService>(
nameof<SynonymGroupApiService>(), // https://github.com/dsherret/ts-nameof
SynonymGroupApiService
);
export function synonymGroupApiService(baseUrl: string): SynonymGroupApiService {
return serviceCacher.instance(baseUrl);
}
So it's a little different here when we create the instance for the service.
We create a function (like a factory) that can be called to get the instance from the cache or the Service Cacher will create a new one for us.
Now it can be called like so:
await synonymGroupApiService(portalUrl).findAllPaged({ search: search.value });
Please feel free to tell me what you think about that and provide feedback.
If the blog article helped you in any way, please also feel free to tell me :)
This was my first written blog post in my life <3
Top comments (0)