forked from TrustUp-app/TrustUp-Frontend
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapi.ts
More file actions
199 lines (172 loc) · 6.39 KB
/
Copy pathapi.ts
File metadata and controls
199 lines (172 loc) · 6.39 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
import { getAccessToken, clearTokens } from './auth-storage';
import { notifyUnauthorized, setUnauthorizedHandler } from './auth-events';
export { setUnauthorizedHandler };
export type FieldErrors = Record<string, string>;
/**
* Base URL for the backend API, read from the public Expo env variable.
* See `.env.example` for the expected value (already includes `/api/v1`).
*/
export const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL ?? '';
export class ApiError extends Error {
status: number;
fieldErrors?: FieldErrors;
constructor(status: number, message: string, fieldErrors?: FieldErrors) {
super(message);
this.name = 'ApiError';
this.status = status;
this.fieldErrors = fieldErrors;
}
}
/**
* Best-effort extraction of per-field validation errors from a REST error
* body. Covers three common shapes since the real backend contract isn't
* documented anywhere in this repo: NestJS class-validator's default
* `{ message: string[] }` (field name assumed to prefix each sentence),
* `{ errors: { field: string | string[] } }`, and
* `{ errors: [{ field | property, message }] }`. Returns undefined if none
* match — callers fall back to the flat message.
*/
function parseFieldErrors(body: unknown, knownFields: string[]): FieldErrors | undefined {
if (!body || typeof body !== 'object') return undefined;
const result: FieldErrors = {};
const maybeErrors = (body as { errors?: unknown }).errors;
if (maybeErrors && typeof maybeErrors === 'object' && !Array.isArray(maybeErrors)) {
for (const [key, value] of Object.entries(maybeErrors as Record<string, unknown>)) {
if (knownFields.includes(key)) {
result[key] = Array.isArray(value) ? String(value[0]) : String(value);
}
}
}
if (Array.isArray(maybeErrors)) {
for (const item of maybeErrors) {
if (!item || typeof item !== 'object') continue;
const field =
(item as { field?: unknown; property?: unknown }).field ??
(item as { property?: unknown }).property;
const constraints = (item as { constraints?: Record<string, string> }).constraints;
const message =
(item as { message?: unknown }).message ??
(constraints ? Object.values(constraints)[0] : undefined);
if (typeof field === 'string' && knownFields.includes(field) && message) {
result[field] = String(message);
}
}
}
const message = (body as { message?: unknown }).message;
if (Array.isArray(message)) {
for (const entry of message) {
if (typeof entry !== 'string') continue;
const field = knownFields.find((f) => entry.startsWith(f));
if (field) result[field] = entry;
}
}
return Object.keys(result).length > 0 ? result : undefined;
}
/**
* Some TrustUp endpoints wrap payloads as `{ success, data, message }`.
* Returns the inner `data` when present; otherwise the body as-is.
*/
export const unwrapApiData = <T>(body: unknown): T => {
if (body && typeof body === 'object' && 'data' in body) {
return (body as { data: T }).data;
}
return body as T;
};
/**
* Thin fetch wrapper that prefixes {@link API_BASE_URL}, attaches the stored
* Bearer token, and parses JSON responses.
*
* @param knownFields Optional list of form field names — when present, the
* error body is inspected for per-field validation errors (see
* {@link parseFieldErrors}) and attached to the thrown ApiError.
* @throws {ApiError} when the response status is not in the 2xx range.
*/
export const apiFetch = async <T>(
path: string,
options: RequestInit = {},
knownFields?: string[]
): Promise<T> => {
const token = await getAccessToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string> | undefined),
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
if (!API_BASE_URL) {
throw new ApiError(0, 'EXPO_PUBLIC_API_URL is not configured');
}
const response = await fetch(`${API_BASE_URL}${path}`, { ...options, headers });
if (response.status === 401) {
await clearTokens();
notifyUnauthorized();
throw new ApiError(401, 'Session expired. Please sign in again.');
}
if (!response.ok) {
let message = `Request failed with status ${response.status}`;
let fieldErrors: FieldErrors | undefined;
try {
const body = await response.json();
if (typeof body?.message === 'string') {
message = body.message;
} else if (Array.isArray(body?.message)) {
message = body.message.join(', ');
}
fieldErrors = parseFieldErrors(body, knownFields ?? []);
} catch {
// Non-JSON error body; keep the default message.
}
throw new ApiError(response.status, message, fieldErrors);
}
if (response.status === 204) {
return undefined as T;
}
const json = await response.json();
return unwrapApiData<T>(json);
};
/**
* Variant of {@link apiFetch} for multipart/form-data bodies (e.g. register
* with an optional profile image). Does not set Content-Type — fetch/RN sets
* the multipart boundary automatically when the body is a FormData instance.
*
* @param knownFields Optional list of form field names for per-field error
* mapping, same as {@link apiFetch}.
*/
export const apiFetchForm = async <T>(
path: string,
formData: FormData,
knownFields?: string[]
): Promise<T> => {
const token = await getAccessToken();
const headers: Record<string, string> = {};
if (token) headers.Authorization = `Bearer ${token}`;
if (!API_BASE_URL) {
throw new ApiError(0, 'EXPO_PUBLIC_API_URL is not configured');
}
const response = await fetch(`${API_BASE_URL}${path}`, {
method: 'POST',
headers,
body: formData,
});
if (response.status === 401) {
await clearTokens();
notifyUnauthorized();
throw new ApiError(401, 'Session expired. Please sign in again.');
}
if (!response.ok) {
let message = `Request failed with status ${response.status}`;
let fieldErrors: FieldErrors | undefined;
try {
const body = await response.json();
if (typeof body?.message === 'string') message = body.message;
else if (Array.isArray(body?.message)) message = body.message.join(', ');
fieldErrors = parseFieldErrors(body, knownFields ?? []);
} catch {
// keep default message
}
throw new ApiError(response.status, message, fieldErrors);
}
const json = await response.json();
return unwrapApiData<T>(json);
};