Creating an axios service wrapper (in Vue)

shinigami92

Shinigami

Posted on June 15, 2021

Creating an axios service wrapper (in Vue)

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
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

(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);
}
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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

💖 💪 🙅 🚩
shinigami92
Shinigami

Posted on June 15, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related