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.
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 tohttp://localhost:8000/api/v1) - Auth token: Automatically reads from
useAuthStore. If no token in memory, callsauthStore.hydrate()to restore fromsessionStorage - FormData detection: When the body is a
FormDatainstance, theContent-Typeheader 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:
// 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
| Type | Use 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.
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
| Method | HTTP | Endpoint | Body | Returns |
|---|---|---|---|---|
get | GET | /{url}?params | -- | PaginatedResourceResponse<Model> |
find | GET | /{url}/{id} | -- | Model (unwrapped from ApiResponse) |
create | POST | /{url} | JSON | Model (unwrapped) |
createFormData | POST | /{url} | FormData | Model (unwrapped) |
update | PUT | /{url}/{id} | JSON | Model (unwrapped) |
updateFormData | PUT | /{url}/{id} | FormData | Model (unwrapped) |
remove | DELETE | /{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
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/catchwithSentry.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
TfromApiResponse<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
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
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 } loadingis aref<boolean>-- alwaystrueduring the request,falseaftererroris aref<string>-- empty string when no error, message string on failuresuccessis aref<boolean>--trueafter 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.
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
<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
| Pattern | When to use |
|---|---|
| Service + Composable + Store | Complex 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.
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
searchKeyfilter is debounced bydebounceMsmilliseconds 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 value | Description |
|---|---|
filters | Reactive object with current filter values |
currentPage | Current 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().
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=15Adding 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:
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:
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:
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:
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.
