Skip to content

Commit 7177b0d

Browse files
committed
API: fully reworked the general api module.
1 parent fa510c1 commit 7177b0d

File tree

1 file changed

+200
-0
lines changed

1 file changed

+200
-0
lines changed

src/shared/api/api.ts

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import { appEventBus, AppEvents } from '@/shared/lib/event-bus';
2+
import type { components } from '@/shared/types/api-types';
3+
4+
const API_BASE_URL = '/api/v2';
5+
6+
type ErrorResponse = components['schemas']['ErrorResponseDto'];
7+
type ErrorPayload = components['schemas']['ErrorPayloadDto'];
8+
type RequestMeta = components['schemas']['RequestMeta'] & { metaCode?: string };
9+
type PaginationMeta = components['schemas']['PaginationMetaDto'];
10+
11+
export interface ApiResponse<T> {
12+
data: T;
13+
meta: RequestMeta & Partial<PaginationMeta>;
14+
}
15+
16+
export class ApiError extends Error {
17+
status: number;
18+
errorResponse: ErrorPayload | null;
19+
metaCode?: string;
20+
21+
constructor(
22+
message: string,
23+
status: number,
24+
errorResponse: ErrorPayload | null,
25+
metaCode?: string,
26+
) {
27+
super(message);
28+
this.name = 'ApiError';
29+
this.status = status;
30+
this.errorResponse = errorResponse;
31+
this.metaCode = metaCode;
32+
}
33+
}
34+
35+
interface RequestOptions extends Omit<RequestInit, 'body'> {
36+
params?: Record<string, string | number | boolean | undefined | null>;
37+
body?: unknown;
38+
/**
39+
* If true, 401 errors will not trigger the global logout event.
40+
*/
41+
skipGlobalErrorHandler?: boolean;
42+
}
43+
44+
interface StructuredErrorMessage {
45+
message: string | string[];
46+
error?: string;
47+
statusCode?: number;
48+
}
49+
50+
function isStructuredError(obj: unknown): obj is StructuredErrorMessage {
51+
return (
52+
typeof obj === 'object' &&
53+
obj !== null &&
54+
'message' in obj &&
55+
(typeof (obj as StructuredErrorMessage).message === 'string' ||
56+
Array.isArray((obj as StructuredErrorMessage).message))
57+
);
58+
}
59+
60+
function extractErrorMessage(errorPayload: ErrorPayload | null, status: number): string {
61+
if (!errorPayload) {
62+
return `HTTP Error ${status}`;
63+
}
64+
65+
const { message } = errorPayload;
66+
67+
if (typeof message === 'string') {
68+
return message;
69+
}
70+
71+
if (isStructuredError(message)) {
72+
const msgContent = message.message;
73+
if (Array.isArray(msgContent)) {
74+
return msgContent.join(', ');
75+
}
76+
return msgContent;
77+
}
78+
79+
return 'An unexpected error occurred.';
80+
}
81+
82+
/**
83+
* Validates if an object matches the expected API Envelope structure.
84+
*/
85+
function isApiEnvelope(obj: unknown): obj is { data: unknown; meta: unknown } {
86+
return typeof obj === 'object' && obj !== null && 'data' in obj && 'meta' in obj;
87+
}
88+
89+
async function request<T>(endpoint: string, options: RequestOptions = {}): Promise<ApiResponse<T>> {
90+
const { params, body: requestBody, skipGlobalErrorHandler, ...fetchNativeOptions } = options;
91+
let url = `${API_BASE_URL}${endpoint}`;
92+
93+
if (params) {
94+
const queryParams = new URLSearchParams();
95+
Object.entries(params).forEach(([key, value]) => {
96+
if (value !== undefined && value !== null && value !== '') {
97+
queryParams.append(key, String(value));
98+
}
99+
});
100+
if (queryParams.toString()) {
101+
url += `?${queryParams.toString()}`;
102+
}
103+
}
104+
105+
const token = localStorage.getItem('token');
106+
const headers = new Headers(fetchNativeOptions.headers || {});
107+
108+
if (
109+
!headers.has('Content-Type') &&
110+
!(requestBody instanceof FormData) &&
111+
requestBody !== undefined &&
112+
requestBody !== null
113+
) {
114+
headers.append('Content-Type', 'application/json');
115+
}
116+
117+
if (token) {
118+
headers.append('Authorization', `Bearer ${token}`);
119+
}
120+
121+
let processedBody: BodyInit | undefined;
122+
if (requestBody !== undefined && requestBody !== null) {
123+
if (requestBody instanceof FormData) {
124+
processedBody = requestBody;
125+
} else if (
126+
typeof requestBody === 'string' ||
127+
requestBody instanceof Blob ||
128+
requestBody instanceof ArrayBuffer ||
129+
requestBody instanceof URLSearchParams ||
130+
requestBody instanceof ReadableStream ||
131+
ArrayBuffer.isView(requestBody)
132+
) {
133+
processedBody = requestBody as BodyInit;
134+
} else {
135+
processedBody = JSON.stringify(requestBody);
136+
}
137+
}
138+
139+
try {
140+
const fetchResponse = await fetch(url, {
141+
...fetchNativeOptions,
142+
headers,
143+
body: processedBody,
144+
});
145+
146+
const fallbackMeta: RequestMeta = {
147+
requestId: fetchResponse.headers.get('x-request-id') || 'unknown-no-content',
148+
apiVersion: '2',
149+
timestamp: new Date().toISOString(),
150+
};
151+
152+
if (fetchResponse.status === 204 || fetchResponse.status === 205) {
153+
return { data: null as T, meta: fallbackMeta };
154+
}
155+
156+
if (fetchResponse.status === 401 && !skipGlobalErrorHandler) {
157+
appEventBus.dispatch(AppEvents.API_UNAUTHORIZED);
158+
}
159+
160+
const responseJson = (await fetchResponse.json()) as unknown;
161+
162+
if (!fetchResponse.ok) {
163+
const errorEnvelope = responseJson as ErrorResponse;
164+
const errorPayload = errorEnvelope.error;
165+
const errorMessage = extractErrorMessage(errorPayload, fetchResponse.status);
166+
const metaCode = (errorEnvelope.meta as RequestMeta | undefined)?.metaCode;
167+
168+
throw new ApiError(errorMessage, fetchResponse.status, errorPayload, metaCode);
169+
}
170+
171+
if (isApiEnvelope(responseJson)) {
172+
return responseJson as ApiResponse<T>;
173+
}
174+
175+
return { data: responseJson as T, meta: fallbackMeta };
176+
} catch (error) {
177+
if (error instanceof ApiError) {
178+
throw error;
179+
}
180+
throw new ApiError(
181+
error instanceof Error ? error.message : 'Network error',
182+
0,
183+
null,
184+
'NetworkError',
185+
);
186+
}
187+
}
188+
189+
export const api = {
190+
get: <T>(endpoint: string, options?: Omit<RequestOptions, 'body'>) =>
191+
request<T>(endpoint, { ...options, method: 'GET' }),
192+
post: <T>(endpoint: string, data?: unknown, options?: Omit<RequestOptions, 'body'>) =>
193+
request<T>(endpoint, { ...options, method: 'POST', body: data }),
194+
put: <T>(endpoint: string, data?: unknown, options?: Omit<RequestOptions, 'body'>) =>
195+
request<T>(endpoint, { ...options, method: 'PUT', body: data }),
196+
patch: <T>(endpoint: string, data?: unknown, options?: Omit<RequestOptions, 'body'>) =>
197+
request<T>(endpoint, { ...options, method: 'PATCH', body: data }),
198+
delete: <T>(endpoint: string, options?: RequestOptions) =>
199+
request<T>(endpoint, { ...options, method: 'DELETE' }),
200+
};

0 commit comments

Comments
 (0)