Creating an axios service wrapper (in Vue)
Tags: javascript, vue, axios
Published: 2021-06-15 18:54:39

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.

```ts
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

```ts
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.

```ts
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`

```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.

```ts
// 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_)

```ts
// 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:

```ts
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_