Skip to content

Last updated:

API Integration Standards

CPR uses a layered architecture for API integration. Each layer has a single responsibility, and data flows in one direction from the API response up to the component.

Layer Diagram

┌──────────────────────────────────────────────────────────┐
│  Component (page or form)                                │
│  Uses composable, reads store, shows loading/error       │
├──────────────────────────────────────────────────────────┤
│  Composable (e.g., useDoctor)                            │
│  Calls service, populates store, exposes loading/error   │
├──────────────────────────────────────────────────────────┤
│  Service (e.g., DoctorService)                           │
│  Calls useApiFetch, catches errors, reports to Sentry    │
├──────────────────────────────────────────────────────────┤
│  useApiFetch                                             │
│  Wraps $fetch, adds Bearer token, sets headers           │
├──────────────────────────────────────────────────────────┤
│  $fetch (ofetch)                                         │
│  Actual HTTP request to API_URL                          │
└──────────────────────────────────────────────────────────┘

Data direction: Component calls composable, composable calls service, service calls useApiFetch, useApiFetch calls $fetch. Response data flows back up the chain.

useApiFetch

Location: app/composables/useApiFetch.ts

The core HTTP wrapper that every service uses. It handles auth token injection, content type headers, and base URL resolution.

typescript
import { useRuntimeConfig } from '#imports';
import { useAuthStore } from '~/stores/auth.store';
import { $fetch } from 'ofetch';

interface ApiFetchOptions extends RequestInit {
  params?: Record<string, string | number | boolean | undefined> | unknown;
  body?: unknown;
}

export const useApiFetch = async <T>(
  endpoint: string,
  options: ApiFetchOptions = {}
): Promise<T> => {
  const config = useRuntimeConfig();
  const baseUrl = (config.public?.API_URL as string) || '';
  const authStore = useAuthStore();

  if (!authStore.state.token) {
    authStore.hydrate();
  }

  const url = `${baseUrl}${endpoint.startsWith('/') ? endpoint : '/' + endpoint}`;

  const isFormData = options.body instanceof FormData;

  const headers: Record<string, string> = {
    Accept: 'application/json',
    ...(options.headers as Record<string, string>),
  };

  // Only set JSON content-type if body is NOT FormData
  if (!isFormData) {
    headers['Content-Type'] = 'application/json';
  }

  if (authStore.state.token) {
    headers['Authorization'] = `Bearer ${authStore.state.token}`;
  }

  return $fetch<T>(url, {
    ...options,
    headers,
  });
};

Key behaviors

  • Base URL: Read from runtimeConfig.public.API_URL (defaults to http://localhost:8000/api/v1)
  • Auth token: Automatically reads from useAuthStore. If no token in memory, calls authStore.hydrate() to restore from sessionStorage
  • FormData detection: When the body is a FormData instance, the Content-Type header is omitted so the browser sets the correct multipart boundary
  • Generic return type: Callers specify the expected response shape via useApiFetch<PaginatedResourceResponse<Doctor>>(...)

API Response Types

Location: app/types/apiReponse.type.ts

All API responses from the backend follow predictable shapes:

typescript
// Single resource response: { data: T, message: string }
export interface ApiResponse<T> {
  data: T;
  message: string;
}

// Paginated response with Laravel-style meta and links
export interface PaginatedResourceResponse<T> {
  data: T[];
  meta: MetaResponse;
  links: LinkResponse;
  message: string;
}

// Flattened paginated data (used by composables after processing)
export interface paginatedData<T> extends APIPagination {
  data: T;
}

// Pagination metadata
export interface APIPagination {
  per_page?: number;
  total?: number;
  from?: number;
  to?: number;
  current_page?: number;
  last_page?: number;
}

// Query params for list endpoints
export interface GetApiParams<T> {
  search?: string;
  filters?: T;
  page?: number;
  perPage?: number;
  sortField?: string;
  sortDirection?: 'asc' | 'desc';
}

When to use which type

TypeUse case
ApiResponse<T>Single resource: find by ID, create, update, delete
PaginatedResourceResponse<T>Paginated list endpoints
paginatedData<T>After a service transforms PaginatedResourceResponse into a flat structure
GetApiParams<T>Input to BasicCrudService.get() via generateQueryStringFromParams

BasicCrudService

Location: app/sevices/basicCrud.service.ts

A base class that provides standard CRUD methods. Domain services can extend it or follow the same pattern independently.

typescript
class BasicCrudService {
  public url: string = '';

  constructor(url: string) {
    this.url = url;
  }

  private getErrorMessage(err: unknown): string {
    const fetchError = err as FetchError<unknown>;
    return (
      (fetchError.data as { message?: string })?.message ??
      fetchError.message ??
      'Something went wrong'
    );
  }

  // Paginated list
  async get<Model, T>(query: GetApiParams<T>): Promise<PaginatedResourceResponse<Model>> { ... }

  // Single resource by ID
  async find<Model>(id: string): Promise<Model> { ... }

  // Create with JSON body
  async create<Model, T>(params: T): Promise<Model> { ... }

  // Create with FormData (file uploads)
  async createFormData<Model, T extends object>(params: T): Promise<Model> { ... }

  // Update with JSON body
  async update<Model, T>(id: string, params: T): Promise<Model> { ... }

  // Update with FormData (file uploads)
  async updateFormData<Model, T extends object>(id: string, params: T): Promise<Model> { ... }

  // Delete
  async remove<Model>(id: string): Promise<Model> { ... }
}

Method summary

MethodHTTPEndpointBodyReturns
getGET/{url}?params--PaginatedResourceResponse<Model>
findGET/{url}/{id}--Model (unwrapped from ApiResponse)
createPOST/{url}JSONModel (unwrapped)
createFormDataPOST/{url}FormDataModel (unwrapped)
updatePUT/{url}/{id}JSONModel (unwrapped)
updateFormDataPUT/{url}/{id}FormDataModel (unwrapped)
removeDELETE/{url}/{id}--Model (unwrapped)

Every method follows the same error handling pattern: catch, extract message from FetchError.data.message, capture to Sentry, re-throw as Error.

Domain Service Pattern

Location: app/sevices/[entity].service.ts

Domain services handle entity-specific API logic. Some extend BasicCrudService, while others implement methods directly when the API shape differs from the standard CRUD pattern.

Example: DoctorService

typescript
import type { ApiResponse, paginatedData, PaginatedResourceResponse } from '~/types/apiReponse.type';
import type { FetchError } from 'ofetch';
import * as Sentry from '@sentry/nuxt';
import type { Doctor, DoctorFilters } from '~/types/doctor.type';

class DoctorService {
  private getErrorMessage(err: unknown): string {
    const fetchError = err as FetchError<unknown>;
    return (
      (fetchError.data as { message?: string })?.message ??
      fetchError.message ??
      'Something went wrong'
    );
  }

  async getDoctors(filters: DoctorFilters = {}): Promise<paginatedData<Doctor[]>> {
    try {
      const params: Record<string, string> = {};
      if (filters.search?.trim() && filters.search !== 'undefined') {
        params.search = filters.search;
      }
      if (filters.status) params.status = filters.status;
      if (filters.doctor_role_id) params.doctor_role_id = String(filters.doctor_role_id);
      if (filters.page) params.page = String(filters.page);
      if (filters.perPage) params.per_page = String(filters.perPage);

      const res = await useApiFetch<PaginatedResourceResponse<Doctor>>('/doctors', {
        method: 'GET',
        params,
      });

      return { data: res.data, ...res.meta, links: res.links } as paginatedData<Doctor[]>;
    } catch (err: unknown) {
      const message = this.getErrorMessage(err);
      Sentry.captureException(err);
      throw new Error(message);
    }
  }

  async getDoctor(id: number): Promise<Doctor> { ... }
  async createDoctor(params: Partial<Doctor>): Promise<Doctor> { ... }
  async updateDoctor(id: number, params: Partial<Doctor>): Promise<Doctor> { ... }
  async deleteDoctor(id: number): Promise<void> { ... }
}

export const doctorService = new DoctorService();
export default doctorService;

Service conventions

  • Services are classes, exported as singleton instances
  • Each method has its own try/catch with Sentry.captureException
  • Error messages are extracted from FetchError.data.message (Laravel validation errors)
  • List methods accept a filters object and build query params internally
  • List methods return paginatedData<T[]> (flattened pagination + data)
  • Single-resource methods return the unwrapped T from ApiResponse<T>.data

Composable Data-Fetching Pattern

Location: app/composables/[entity]/

Each domain has composables that wrap service calls and expose reactive state. Every composable follows the same structure:

useDoctor -- Fetch/list composable

typescript
import doctorService from '~/sevices/doctor.service';
import { useDoctorStore } from '~/stores/doctor.store';
import type { DoctorFilters } from '~/types/doctor.type';

export const useDoctor = () => {
  const doctorStore = useDoctorStore();

  const success = ref(false);
  const loading = ref(false);
  const error = ref('');

  const getDoctors = async (search?: string, filters?: { status?: string }, page?: number) => {
    loading.value = true;

    try {
      const doctorFilters: DoctorFilters = {};
      if (search) doctorFilters.search = search;
      if (filters?.status) doctorFilters.status = filters.status;
      if (page) doctorFilters.page = page;

      const paginatedDoctors = await doctorService.getDoctors(doctorFilters);

      const { data, ...pagination } = paginatedDoctors;
      doctorStore.list = data;
      doctorStore.pagination = pagination;

      error.value = '';
      success.value = true;
    } catch (err) {
      const fetchError = err as { data?: { message?: string }; message?: string };
      error.value = fetchError.data?.message ?? fetchError.message ?? 'Something went wrong';
      success.value = false;
    } finally {
      loading.value = false;
    }
  };

  return { loading, error, success, getDoctors };
};

useCreateDoctor -- Mutation composable

typescript
import doctorService from '~/sevices/doctor.service';
import type { Doctor } from '~/types/doctor.type';

export const useCreateDoctor = () => {
  const success = ref(false);
  const loading = ref(false);
  const error = ref('');

  const createDoctor = async (params: Partial<Doctor>) => {
    loading.value = true;
    success.value = false;
    error.value = '';

    try {
      await doctorService.createDoctor(params);
      success.value = true;
    } catch (err) {
      const message = err instanceof Error ? err.message : 'Something went wrong';
      error.value = message;
      success.value = false;
    } finally {
      loading.value = false;
    }
  };

  return { loading, error, success, createDoctor };
};

Composable conventions

  • Every composable returns { loading, error, success, ...methods }
  • loading is a ref<boolean> -- always true during the request, false after
  • error is a ref<string> -- empty string when no error, message string on failure
  • success is a ref<boolean> -- true after a successful operation
  • Fetch composables populate the Pinia store; mutation composables do not (the page refetches after mutations)
  • One composable per operation: useDoctor, useCreateDoctor, useUpdateDoctor, useDeleteDoctor

useApi -- Generic CRUD Composable

Location: app/composables/useApi.ts

An alternative pattern for simpler CRUD scenarios. Instead of writing a service + composable + store per entity, useApi provides all CRUD operations as a single composable with built-in state management.

typescript
export const useApi = <T extends { id: number | string }>(resource: string) => {
  const items = ref<T[]>([]);
  const pagination = ref({ current_page: 1, last_page: 1, per_page: 10, total: 0 });
  const loading = ref(false);
  const error = ref<string | null>(null);

  const fetchItems = async (params?: Record<string, string | number | boolean | null | undefined>) => { ... };
  const addItem = async (data: Partial<T>) => { ... };
  const updateItem = async (id: number | string, data: Partial<T>) => { ... };
  const deleteItem = async (id: number | string) => { ... };

  return { items, pagination, loading, error, fetchItems, addItem, updateItem, deleteItem };
};

Usage

vue
<script setup lang="ts">
import type { PaymentMethod } from '~/types/paymentMethod.type';

const { items, pagination, loading, error, fetchItems, addItem, deleteItem } =
  useApi<PaymentMethod>('payment-methods');

onMounted(() => fetchItems());
</script>

When to use which pattern

PatternWhen to use
Service + Composable + StoreComplex entities with custom API logic, custom filters, multiple pages consuming the same data
useApi<T>Simple CRUD entities with standard endpoints, data only used in one page

useListFilters

Location: app/composables/useListFilters.ts

A reusable composable for paginated list pages. It handles search debounce, filter watching, pagination, and URL query synchronization.

typescript
const { filters, currentPage, filter, setPage, watchImmediateFilter, refetchCurrentPage, syncFromRoute } =
  useListFilters({
    initialFilters: {
      search: '',
      status: undefined,
    },
    searchKey: 'search',      // Which filter key to debounce (default: 'search')
    pageKey: 'page',          // URL query param name for pagination (default: 'page')
    debounceMs: 300,          // Debounce delay for search input (default: 300)
    onFilter: async (params) => {
      // Called when filters change -- fetch data here
      await doctor.getDoctors(params.search, { status: params.status }, params.page);
    },
  });

Features

  • Search debounce: The searchKey filter is debounced by debounceMs milliseconds to avoid excessive API calls during typing
  • Immediate filters: Register filter keys that should trigger an API call immediately (no debounce):
    typescript
    watchImmediateFilter(['status']); // status changes trigger instant refetch
  • URL sync: Filter values and the current page are written to the URL query string, so users can bookmark or share filtered views
  • Pagination: setPage(page) updates the page, syncs the URL, and refetches
  • Refetch: refetchCurrentPage() forces a refetch of the current page (used after create/update/delete operations)
  • Deduplication: Skips redundant API calls if params have not changed since the last fetch

Returned values

Return valueDescription
filtersReactive object with current filter values
currentPageCurrent page number (ref)
filter()Execute the onFilter callback with current params
setPage(page)Change page, update URL, refetch
watchImmediateFilter(keys)Watch specific filter keys for instant refetch
refetchCurrentPage()Force refetch current page
syncFromRoute()Re-read filters from URL (for browser back/forward)
updateQuery()Push current filters to URL without fetching

generateQueryStringFromParams

Location: app/utils/apiRequesUtils.ts

Converts a GetApiParams<T> object into flat query parameters suitable for the API. Used by BasicCrudService.get().

typescript
import type { GetApiParams } from '~/types/apiReponse.type';

export const generateQueryStringFromParams = <T>(query: GetApiParams<T>): FilterParams => {
  const params: FilterParams = {};

  if (query.search) params.search = query.search;

  // Filters are nested as filters[key]=value
  if (query.filters && Object.keys(query.filters).length) {
    for (const key in query.filters) {
      const value = query.filters[key as keyof typeof query.filters];
      if (value !== undefined) {
        params[`filters[${key}]`] = value as string | number | boolean;
      }
    }
  }

  if (query.page) params.page = query.page;
  if (query.perPage) params.per_page = query.perPage;
  if (query.sortField) params.sort_field = query.sortField;
  if (query.sortDirection) params.sort_direction = query.sortDirection;

  return params;
};

Filter parameters are sent as filters[key]=value (Laravel query filter convention). For example:

GET /doctors?search=Juan&filters[status]=active&page=1&per_page=15

Adding a New API Endpoint

Follow these steps to add a new endpoint for an entity called "Appointment":

Step 1: Define types

Create app/types/appointment.type.ts:

typescript
export interface Appointment {
  id: number;
  patient_id: number;
  doctor_id: number;
  scheduled_at: string;
  status: string;
  notes: string | null;
  created_at: string;
  updated_at: string;
}

export interface AppointmentFilters {
  search?: string;
  status?: string;
  doctor_id?: number;
  page?: number;
  perPage?: number;
}

Step 2: Create the service

Create app/sevices/appointment.service.ts:

typescript
import type { ApiResponse, paginatedData, PaginatedResourceResponse } from '~/types/apiReponse.type';
import type { FetchError } from 'ofetch';
import * as Sentry from '@sentry/nuxt';
import type { Appointment, AppointmentFilters } from '~/types/appointment.type';

class AppointmentService {
  private getErrorMessage(err: unknown): string {
    const fetchError = err as FetchError<unknown>;
    return (
      (fetchError.data as { message?: string })?.message ??
      fetchError.message ??
      'Something went wrong'
    );
  }

  async getAppointments(
    filters: AppointmentFilters = {}
  ): Promise<paginatedData<Appointment[]>> {
    try {
      const params: Record<string, string> = {};
      if (filters.search?.trim()) params.search = filters.search;
      if (filters.status) params.status = filters.status;
      if (filters.doctor_id) params.doctor_id = String(filters.doctor_id);
      if (filters.page) params.page = String(filters.page);
      if (filters.perPage) params.per_page = String(filters.perPage);

      const res = await useApiFetch<PaginatedResourceResponse<Appointment>>(
        '/appointments',
        { method: 'GET', params }
      );

      return { data: res.data, ...res.meta, links: res.links } as paginatedData<Appointment[]>;
    } catch (err: unknown) {
      const message = this.getErrorMessage(err);
      Sentry.captureException(err);
      throw new Error(message);
    }
  }

  async createAppointment(params: Partial<Appointment>): Promise<Appointment> {
    try {
      const res: ApiResponse<Appointment> = await useApiFetch('/appointments', {
        method: 'POST',
        body: params,
      });
      return res.data;
    } catch (err: unknown) {
      const message = this.getErrorMessage(err);
      Sentry.captureException(err);
      throw new Error(message);
    }
  }

  async updateAppointment(id: number, params: Partial<Appointment>): Promise<Appointment> {
    try {
      const res: ApiResponse<Appointment> = await useApiFetch(`/appointments/${id}`, {
        method: 'PUT',
        body: params,
      });
      return res.data;
    } catch (err: unknown) {
      const message = this.getErrorMessage(err);
      Sentry.captureException(err);
      throw new Error(message);
    }
  }

  async deleteAppointment(id: number): Promise<void> {
    try {
      await useApiFetch(`/appointments/${id}`, { method: 'DELETE' });
    } catch (err: unknown) {
      const message = this.getErrorMessage(err);
      Sentry.captureException(err);
      throw new Error(message);
    }
  }
}

export const appointmentService = new AppointmentService();
export default appointmentService;

Step 3: Create the store

Create app/stores/appointment.store.ts:

typescript
import { defineStore } from 'pinia';
import type { APIPagination } from '~/types/apiReponse.type';
import type { Appointment } from '~/types/appointment.type';

export const useAppointmentStore = defineStore('appointment', () => {
  const list = ref<Appointment[]>([]);
  const pagination = ref<APIPagination>({
    per_page: 15,
    total: 0,
    from: 0,
    to: 0,
    current_page: 1,
  });

  return { list, pagination };
});

Step 4: Create composables

Create app/composables/appointment/useAppointment.ts:

typescript
import appointmentService from '~/sevices/appointment.service';
import { useAppointmentStore } from '~/stores/appointment.store';
import type { AppointmentFilters } from '~/types/appointment.type';

export const useAppointment = () => {
  const store = useAppointmentStore();
  const success = ref(false);
  const loading = ref(false);
  const error = ref('');

  const getAppointments = async (
    search?: string,
    filters?: { status?: string; doctor_id?: number },
    page?: number
  ) => {
    loading.value = true;
    try {
      const params: AppointmentFilters = {};
      if (search) params.search = search;
      if (filters?.status) params.status = filters.status;
      if (filters?.doctor_id) params.doctor_id = filters.doctor_id;
      if (page) params.page = page;

      const result = await appointmentService.getAppointments(params);
      const { data, ...pagination } = result;
      store.list = data;
      store.pagination = pagination;

      error.value = '';
      success.value = true;
    } catch (err) {
      error.value = err instanceof Error ? err.message : 'Something went wrong';
      success.value = false;
    } finally {
      loading.value = false;
    }
  };

  return { loading, error, success, getAppointments };
};

Create similar files for useCreateAppointment.ts, useUpdateAppointment.ts, and useDeleteAppointment.ts following the same pattern as the doctor composables.

Step 5: Wire up in the page

Use the composables in your page component with useListFilters for pagination and filtering. See Component Architecture for the full page composition pattern.

CPR - Clinical Patient Records