feat(useApi): better interface

This commit is contained in:
2025-11-19 22:03:35 +01:00
parent 355653e4f2
commit 0b65e17903
11 changed files with 1080 additions and 106 deletions

View File

@@ -0,0 +1,361 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { nextTick } from 'vue';
import type { FetchError } from 'ofetch';
import type { ApiError } from '~/types/api/error';
import { useApi } from './useApi';
// Mock dependencies
vi.mock('#app', () => ({
useRuntimeConfig: vi.fn(() => ({
public: {
apiBase: 'http://localhost:3100/api',
},
})),
}));
// Mock $fetch globally
global.$fetch = vi.fn();
describe('useApi', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('GET requests', () => {
it('should make a GET request and populate data on success', async () => {
const mockData = { id: 1, name: 'Test' };
vi.mocked($fetch).mockResolvedValueOnce(mockData);
const api = useApi();
const result = api.get<typeof mockData>('/test');
// Should start loading
await nextTick();
expect(result.loading.value).toBe(false); // Immediate execution completes quickly
// Wait for the async operation
await vi.waitFor(() => expect(result.data.value).toStrictEqual(mockData));
expect($fetch).toHaveBeenCalledWith('/test', {
baseURL: 'http://localhost:3100/api',
method: 'GET',
body: undefined,
});
expect(result.data.value).toEqual(mockData);
expect(result.error.value).toBeNull();
expect(result.loading.value).toBe(false);
});
it('should handle GET request with custom options', async () => {
const mockData = { result: 'success' };
vi.mocked($fetch).mockResolvedValueOnce(mockData);
const api = useApi();
const result = api.get('/test', { headers: { 'X-Custom': 'header' } });
await vi.waitFor(() => expect(result.data.value).toStrictEqual(mockData));
expect($fetch).toHaveBeenCalledWith('/test', {
baseURL: 'http://localhost:3100/api',
method: 'GET',
headers: { 'X-Custom': 'header' },
body: undefined,
});
});
it('should not execute immediately when immediate is false', async () => {
const api = useApi();
const result = api.get('/test', {}, false);
expect($fetch).not.toHaveBeenCalled();
expect(result.data.value).toBeNull();
expect(result.loading.value).toBe(false);
});
it('should execute when run() is called manually', async () => {
const mockData = { manual: true };
vi.mocked($fetch).mockResolvedValueOnce(mockData);
const api = useApi();
const result = api.get('/test', {}, false);
expect($fetch).not.toHaveBeenCalled();
await result.run();
expect($fetch).toHaveBeenCalledWith('/test', {
baseURL: 'http://localhost:3100/api',
method: 'GET',
body: undefined,
});
expect(result.data.value).toEqual(mockData);
});
});
describe('DELETE requests', () => {
it('should make a DELETE request', async () => {
const mockData = { deleted: true };
vi.mocked($fetch).mockResolvedValueOnce(mockData);
const api = useApi();
const result = api.del<typeof mockData>('/test/1');
await vi.waitFor(() => expect(result.data.value).toStrictEqual(mockData));
expect($fetch).toHaveBeenCalledWith('/test/1', {
baseURL: 'http://localhost:3100/api',
method: 'DELETE',
body: undefined,
});
expect(result.data.value).toEqual(mockData);
});
});
describe('POST requests', () => {
it('should make a POST request with body', async () => {
const mockResponse = { id: 1, created: true };
const requestBody = { name: 'New Item' };
vi.mocked($fetch).mockResolvedValueOnce(mockResponse);
const api = useApi();
const result = api.post<typeof mockResponse, typeof requestBody>(
'/test',
{},
true,
requestBody,
);
await vi.waitFor(() => expect(result.data.value).toStrictEqual(mockResponse));
expect($fetch).toHaveBeenCalledWith('/test', {
baseURL: 'http://localhost:3100/api',
method: 'POST',
body: requestBody,
});
expect(result.data.value).toEqual(mockResponse);
});
it('should allow run() to be called with a different body', async () => {
const mockResponse = { success: true };
vi.mocked($fetch).mockResolvedValueOnce(mockResponse);
const api = useApi();
const result = api.post<typeof mockResponse, { data: string }>('/test', {}, false);
const body = { data: 'runtime-data' };
await result.run(body);
expect($fetch).toHaveBeenCalledWith('/test', {
baseURL: 'http://localhost:3100/api',
method: 'POST',
body,
});
expect(result.data.value).toEqual(mockResponse);
});
});
describe('PUT requests', () => {
it('should make a PUT request with body', async () => {
const mockResponse = { updated: true };
const requestBody = { name: 'Updated Item' };
vi.mocked($fetch).mockResolvedValueOnce(mockResponse);
const api = useApi();
const result = api.put<typeof mockResponse, typeof requestBody>(
'/test/1',
{},
true,
requestBody,
);
await vi.waitFor(() => expect(result.data.value).toStrictEqual(mockResponse));
expect($fetch).toHaveBeenCalledWith('/test/1', {
baseURL: 'http://localhost:3100/api',
method: 'PUT',
body: requestBody,
});
});
});
describe('PATCH requests', () => {
it('should make a PATCH request with body', async () => {
const mockResponse = { patched: true };
const requestBody = { field: 'value' };
vi.mocked($fetch).mockResolvedValueOnce(mockResponse);
const api = useApi();
const result = api.patch<typeof mockResponse, typeof requestBody>(
'/test/1',
{},
true,
requestBody,
);
await vi.waitFor(() => expect(result.data.value).toStrictEqual(mockResponse));
expect($fetch).toHaveBeenCalledWith('/test/1', {
baseURL: 'http://localhost:3100/api',
method: 'PATCH',
body: requestBody,
});
});
});
describe('Error handling', () => {
it('should handle fetch errors with ApiError response', async () => {
const apiError: ApiError = {
message: 'backend.errors.not_found',
success: false,
};
const fetchError: Partial<FetchError> = {
message: 'Fetch Error',
response: {
_data: apiError,
} as any,
};
vi.mocked($fetch).mockRejectedValueOnce(fetchError);
const api = useApi();
const result = api.get('/test');
await vi.waitFor(() => expect(result.error.value).not.toBeNull());
expect(result.data.value).toBeNull();
expect(result.error.value).toEqual(apiError);
expect(result.loading.value).toBe(false);
});
it('should handle fetch errors without ApiError response', async () => {
const fetchError: Partial<FetchError> = {
message: 'Network Error',
response: undefined,
};
vi.mocked($fetch).mockRejectedValueOnce(fetchError);
const api = useApi();
const result = api.get('/test');
await vi.waitFor(() => expect(result.error.value).not.toBeNull());
expect(result.error.value).toEqual({
message: 'Network Error',
success: false,
});
});
it('should use default error message when fetch error has no message', async () => {
const fetchError: Partial<FetchError> = {
message: '',
};
vi.mocked($fetch).mockRejectedValueOnce(fetchError);
const api = useApi();
const result = api.get('/test');
await vi.waitFor(() => expect(result.error.value).not.toBeNull());
expect(result.error.value).toEqual({
message: 'backend.errors.unknown',
success: false,
});
});
it('should clear previous errors on new request', async () => {
const fetchError: Partial<FetchError> = {
message: 'First Error',
};
const mockData = { success: true };
// First request fails
vi.mocked($fetch).mockRejectedValueOnce(fetchError);
const api = useApi();
const result = api.get('/test', {}, false);
await result.run();
await vi.waitFor(() => expect(result.error.value).not.toBeNull());
expect(result.error.value?.message).toBe('First Error');
// Second request succeeds
vi.mocked($fetch).mockResolvedValueOnce(mockData);
await result.run();
await vi.waitFor(() => expect(result.data.value).toStrictEqual(mockData));
expect(result.error.value).toBeNull();
});
});
describe('Loading state', () => {
it('should set loading to true during request', async () => {
let resolvePromise: (value: any) => void;
const promise = new Promise((resolve) => {
resolvePromise = resolve;
});
vi.mocked($fetch).mockReturnValueOnce(promise as any);
const api = useApi();
const result = api.get('/test', {}, false);
expect(result.loading.value).toBe(false);
const runPromise = result.run();
// Should be loading
await nextTick();
expect(result.loading.value).toBe(true);
// Resolve the request
resolvePromise!({ done: true });
await runPromise;
expect(result.loading.value).toBe(false);
});
it('should set loading to false after error', async () => {
let rejectPromise: (error: any) => void;
const promise = new Promise((_, reject) => {
rejectPromise = reject;
});
vi.mocked($fetch).mockReturnValueOnce(promise as any);
const api = useApi();
const result = api.get('/test', {}, false);
const runPromise = result.run();
await nextTick();
expect(result.loading.value).toBe(true);
rejectPromise!({ message: 'Error' });
await runPromise;
expect(result.loading.value).toBe(false);
});
});
describe('Return type structure', () => {
it('should return QueryResult with correct structure', async () => {
vi.mocked($fetch).mockResolvedValueOnce({ test: 'data' });
const api = useApi();
const result = api.get('/test', {}, false);
expect(result).toHaveProperty('data');
expect(result).toHaveProperty('error');
expect(result).toHaveProperty('loading');
expect(result).toHaveProperty('run');
expect(typeof result.run).toBe('function');
});
});
});

View File

@@ -1,32 +1,67 @@
import type { FetchOptions } from 'ofetch';
import type { FetchError, FetchOptions } from 'ofetch';
import type { ApiError } from '~/types/api/error';
import type { HttpMethod } from '~/types/http-method';
import { QueryResult } from '~/types/query-result';
export const useApi = () => {
const config = useRuntimeConfig();
const apiFetch = $fetch.create({
baseURL: config.public.apiBase,
});
export interface UseApi {
get: <T>(path: string, opts?: FetchOptions, immediate?: boolean) => UseApiResponse<T>;
del: <T>(path: string, opts?: FetchOptions, immediate?: boolean) => UseApiResponse<T>;
post: <T, B = unknown>(path: string, opts?: FetchOptions, immediate?: boolean, body?: B) => UseApiResponse<T, B>;
put: <T, B = unknown>(path: string, opts?: FetchOptions, immediate?: boolean, body?: B) => UseApiResponse<T, B>;
patch: <T, B = unknown>(path: string, opts?: FetchOptions, immediate?: boolean, body?: B) => UseApiResponse<T, B>;
}
const get = <T>(url: string, options?: FetchOptions) => apiFetch<T>(url, { method: 'GET', ...options });
const createRequest = <ResponseT = unknown, PayloadT = unknown>(
method: HttpMethod,
url: string,
opts?: FetchOptions,
immediate: boolean = true,
body?: PayloadT,
): QueryResult<ResponseT, PayloadT> => {
const response = new QueryResult<ResponseT, PayloadT>();
const { apiBase } = useRuntimeConfig().public;
const post = <ResultT, PayloadT = Record<string, string | number | boolean>>(
url: string,
body?: PayloadT,
options?: FetchOptions,
) => apiFetch<ResultT>(url, { method: 'POST', body, ...options });
const run = async (requestBody?: PayloadT): Promise<void> => {
response.loading.value = true;
response.error.value = null;
const put = <ResultT, PayloadT = Record<string, string | number | boolean>>(
url: string,
body?: PayloadT,
options?: FetchOptions,
) => apiFetch<ResultT>(url, { method: 'PUT', body, ...options });
try {
const res = await $fetch<ResponseT>(url, {
baseURL: apiBase,
...opts,
method,
body: requestBody ?? undefined,
});
response.data.value = res;
} catch (e) {
const fetchError = e as FetchError;
const errBody = fetchError?.response?._data as ApiError | undefined;
response.error.value = errBody ?? {
message: fetchError.message || 'backend.errors.unknown',
success: false,
};
} finally {
response.loading.value = false;
}
};
response.run = run;
const patch = <ResultT, PayloadT = Record<string, string | number | boolean>>(
url: string,
body?: PayloadT,
options?: FetchOptions,
) => apiFetch<ResultT>(url, { method: 'PATCH', body, ...options });
if (immediate) run(body);
const del = <T>(url: string, options?: FetchOptions) => apiFetch<T>(url, { method: 'DELETE', ...options });
return response;
};
export const useApi = (): UseApi => {
const get = <T>(path: string, opts?: FetchOptions, immediate: boolean = true) =>
createRequest<T>('GET', path, opts, immediate);
const del = <T>(path: string, opts?: FetchOptions, immediate: boolean = true) =>
createRequest<T>('DELETE', path, opts, immediate);
const post = <T, B = unknown>(path: string, opts?: FetchOptions, immediate: boolean = true, body?: B) =>
createRequest<T, B>('POST', path, opts, immediate, body);
const put = <T, B = unknown>(path: string, opts?: FetchOptions, immediate: boolean = true, body?: B) =>
createRequest<T, B>('PUT', path, opts, immediate, body);
const patch = <T, B = unknown>(path: string, opts?: FetchOptions, immediate: boolean = true, body?: B) =>
createRequest<T, B>('PATCH', path, opts, immediate, body);
return { get, post, put, patch, del };
};

View File

@@ -1,8 +1,12 @@
import type { ContactRequest, ContactResponse } from '~/types/api/contact';
import type { MetaResponse } from '~/types/api/meta';
export const useBackend = () => {
const api = useApi();
const getMeta = () => api.get<MetaResponse>('/meta');
const postContact = (contact: ContactRequest) => api.post<ContactRequest, ContactResponse>('/contact', contact);
const getMeta = (): UseApiResponse<MetaResponse> => api.get<MetaResponse>('/meta');
const postContact = (): UseApiResponse<ContactResponse, ContactRequest> =>
api.post<ContactResponse, ContactRequest>('/contact', false);
return { getMeta, postContact };
};