@next-feature/client 0.1.0-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,431 @@
1
+ import axios, {
2
+ AxiosError,
3
+ AxiosInstance,
4
+ AxiosRequestConfig,
5
+ AxiosResponse,
6
+ InternalAxiosRequestConfig,
7
+ } from 'axios';
8
+ import { ApiError, ProblemDetail } from './error';
9
+
10
+ /**
11
+ * Configuration options for the API client
12
+ */
13
+ export interface ApiClientConfig {
14
+ baseURL: string;
15
+ timeout?: number;
16
+ enableRefreshToken?: boolean;
17
+ maxRetries?: number;
18
+ retryDelay?: number;
19
+ onAuthenticated?: (
20
+ config: InternalAxiosRequestConfig
21
+ ) => void | Promise<void>;
22
+ onUnauthorized?: () => void | Promise<void>;
23
+ onRefreshTokenExpired?: () => void | Promise<void>;
24
+ onRefreshToken?: () => string | Promise<string>;
25
+ }
26
+
27
+ /**
28
+ * Pending request queue item
29
+ */
30
+ interface PendingRequest {
31
+ resolve: (token: string) => void;
32
+ reject: (error: any) => void;
33
+ }
34
+
35
+ /**
36
+ * Axios wrapper with JWT/Refresh token handling
37
+ */
38
+ export class ApiClient {
39
+ private readonly instance: AxiosInstance;
40
+ private isRefreshing = false;
41
+ private pendingRequests: PendingRequest[] = [];
42
+ private config: Required<ApiClientConfig>;
43
+
44
+ constructor(config: ApiClientConfig) {
45
+ this.config = {
46
+ timeout: 30000,
47
+ enableRefreshToken: true,
48
+ maxRetries: 3,
49
+ retryDelay: 1000,
50
+ onUnauthorized: async () => {
51
+ // Redirect to login page
52
+ if (typeof window !== 'undefined') {
53
+ window.location.href = '/login';
54
+ }
55
+ },
56
+ onRefreshTokenExpired: async () => {
57
+ console.error('Session expired. Please login again.');
58
+ if (typeof window !== 'undefined') {
59
+ window.location.href = '/login?expired=true';
60
+ }
61
+ },
62
+ onAuthenticated: async (config) => {
63
+ console.log('Authenticated', config.data);
64
+ },
65
+ onRefreshToken: async () => {
66
+ return '';
67
+ },
68
+ ...config,
69
+ };
70
+
71
+ this.instance = axios.create({
72
+ baseURL: this.config.baseURL,
73
+ timeout: this.config.timeout,
74
+ headers: {
75
+ 'Content-Type': 'application/json',
76
+ },
77
+ });
78
+
79
+ this.setupInterceptors();
80
+ }
81
+
82
+ /**
83
+ * Setup request and response interceptors
84
+ */
85
+ private setupInterceptors(): void {
86
+ // Request interceptor
87
+ this.instance.interceptors.request.use(
88
+ this.handleRequestFulfilled.bind(this),
89
+ this.handleRequestRejected.bind(this)
90
+ );
91
+
92
+ // Response interceptor
93
+ this.instance.interceptors.response.use(
94
+ this.handleResponseFulfilled.bind(this),
95
+ this.handleResponseRejected.bind(this)
96
+ );
97
+ }
98
+
99
+ /**
100
+ * Attach JWT token to request headers
101
+ */
102
+ private async handleRequestFulfilled(
103
+ config: InternalAxiosRequestConfig
104
+ ): Promise<InternalAxiosRequestConfig> {
105
+ try {
106
+ if (this.config.onAuthenticated) {
107
+ this.config.onAuthenticated(config);
108
+ }
109
+
110
+ return config;
111
+ } catch (error) {
112
+ console.error('Error attaching token to request:', error);
113
+ return config;
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Handle request errors
119
+ */
120
+ private handleRequestRejected(error: any): Promise<never> {
121
+ console.error('Request configuration error:', error);
122
+ return Promise.reject(error);
123
+ }
124
+
125
+ /**
126
+ * Pass through successful responses
127
+ */
128
+ private handleResponseFulfilled(response: AxiosResponse): AxiosResponse {
129
+ return response;
130
+ }
131
+
132
+ /**
133
+ * Handle response errors with retry logic and token refresh
134
+ */
135
+ private async handleResponseRejected(error: AxiosError): Promise<any> {
136
+ const originalRequest = error.config as InternalAxiosRequestConfig & {
137
+ _retry?: boolean;
138
+ _retryCount?: number;
139
+ };
140
+
141
+ if (!originalRequest) {
142
+ return Promise.reject(this.createApiError(error));
143
+ }
144
+
145
+ // Handle 401 Unauthorized - attempt token refresh
146
+ if (error.response?.status === 401 && this.config.enableRefreshToken) {
147
+ return this.handleUnauthorizedError(error, originalRequest);
148
+ }
149
+
150
+ // Handle network errors and 5xx errors with retry logic
151
+ if (this.shouldRetry(error, originalRequest)) {
152
+ return this.retryRequest(originalRequest);
153
+ }
154
+
155
+ // Create and reject with custom ApiError
156
+ return Promise.reject(this.createApiError(error));
157
+ }
158
+
159
+ /**
160
+ * Handle 401 errors with token refresh
161
+ */
162
+ private async handleUnauthorizedError(
163
+ error: AxiosError,
164
+ originalRequest: InternalAxiosRequestConfig & { _retry?: boolean }
165
+ ): Promise<any> {
166
+ // Prevent infinite loops
167
+ if (originalRequest._retry) {
168
+ if (this.config.onRefreshTokenExpired) {
169
+ await this.config.onRefreshTokenExpired();
170
+ }
171
+ return Promise.reject(this.createApiError(error));
172
+ }
173
+
174
+ originalRequest._retry = true;
175
+
176
+ // If already refreshing, queue the request
177
+ if (this.isRefreshing) {
178
+ return new Promise((resolve, reject) => {
179
+ this.pendingRequests.push({ resolve, reject });
180
+ })
181
+ .then((token) => {
182
+ originalRequest.headers.Authorization = `Bearer ${token}`;
183
+ return this.instance(originalRequest);
184
+ })
185
+ .catch((err) => {
186
+ return Promise.reject(err);
187
+ });
188
+ }
189
+
190
+ this.isRefreshing = true;
191
+
192
+ try {
193
+ const newToken = await this.refreshToken();
194
+
195
+ // Update the original request with new token
196
+ originalRequest.headers.Authorization = `Bearer ${newToken}`;
197
+
198
+ // Resolve all pending requests with new token
199
+ this.processPendingRequests(null, newToken);
200
+
201
+ // Retry the original request
202
+ return this.instance(originalRequest);
203
+ } catch (refreshError) {
204
+ // Reject all pending requests
205
+ this.processPendingRequests(refreshError, null);
206
+
207
+ if (this.config.onUnauthorized) {
208
+ await this.config.onUnauthorized();
209
+ }
210
+
211
+ return Promise.reject(this.createApiError(error));
212
+ } finally {
213
+ this.isRefreshing = false;
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Refresh the JWT token using the refresh token
219
+ */
220
+ private async refreshToken(): Promise<string> {
221
+ try {
222
+ // const session = await auth();
223
+ //
224
+ // if (!session?.user || !('refreshToken' in session.user)) {
225
+ // throw new Error('No refresh token available');
226
+ // }
227
+ //
228
+ // const refreshToken = session.user.refreshToken as string;
229
+ const refreshToken = this.config.onRefreshToken();
230
+
231
+ // Call your refresh token endpoint
232
+ const response = await axios.post<{ jwtToken: string }>(
233
+ `${this.config.baseURL}/auth/refresh`,
234
+ { refreshToken },
235
+ {
236
+ headers: {
237
+ 'Content-Type': 'application/json',
238
+ },
239
+ }
240
+ );
241
+
242
+ const newToken = response.data.jwtToken;
243
+
244
+ // Update the session with the new token
245
+ // Note: You'll need to implement this based on your auth setup
246
+ // This might involve updating cookies or calling an API route
247
+ await this.updateSession(newToken);
248
+
249
+ return newToken;
250
+ } catch (error) {
251
+ console.error('Token refresh failed:', error);
252
+ throw error;
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Update session with new token
258
+ * Implement this based on your Next.js auth setup
259
+ */
260
+ private async updateSession(newToken: string): Promise<void> {
261
+ // Example implementation - adjust based on your auth setup
262
+ try {
263
+ await fetch('/api/auth/update-token', {
264
+ method: 'POST',
265
+ headers: {
266
+ 'Content-Type': 'application/json',
267
+ },
268
+ body: JSON.stringify({ jwtToken: newToken }),
269
+ });
270
+ } catch (error) {
271
+ console.error('Failed to update session:', error);
272
+ throw error;
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Process all pending requests after token refresh
278
+ */
279
+ private processPendingRequests(error: any, token: string | null): void {
280
+ this.pendingRequests.forEach((request) => {
281
+ if (error) {
282
+ request.reject(error);
283
+ } else if (token) {
284
+ request.resolve(token);
285
+ }
286
+ });
287
+
288
+ this.pendingRequests = [];
289
+ }
290
+
291
+ /**
292
+ * Determine if request should be retried
293
+ */
294
+ private shouldRetry(
295
+ error: AxiosError,
296
+ config: InternalAxiosRequestConfig & { _retryCount?: number }
297
+ ): boolean {
298
+ const retryCount = config._retryCount || 0;
299
+
300
+ // Don't retry if max retries exceeded
301
+ if (retryCount >= this.config.maxRetries) {
302
+ return false;
303
+ }
304
+
305
+ // Retry on network errors
306
+ if (!error.response) {
307
+ return true;
308
+ }
309
+
310
+ // Retry on 5xx server errors (except 501)
311
+ const status = error.response.status;
312
+ if (status >= 500 && status !== 501) {
313
+ return true;
314
+ }
315
+
316
+ // Retry on 429 (Too Many Requests)
317
+ if (status === 429) {
318
+ return true;
319
+ }
320
+
321
+ return false;
322
+ }
323
+
324
+ /**
325
+ * Retry failed request with exponential backoff
326
+ */
327
+ private async retryRequest(
328
+ config: InternalAxiosRequestConfig & { _retryCount?: number }
329
+ ): Promise<any> {
330
+ config._retryCount = (config._retryCount || 0) + 1;
331
+
332
+ const delay = this.config.retryDelay * Math.pow(2, config._retryCount - 1);
333
+
334
+ await this.sleep(delay);
335
+
336
+ console.log(
337
+ `Retrying request (attempt ${config._retryCount}):`,
338
+ config.url
339
+ );
340
+
341
+ return this.instance(config);
342
+ }
343
+
344
+ /**
345
+ * Sleep helper for retry delays
346
+ */
347
+ private sleep(ms: number): Promise<void> {
348
+ return new Promise((resolve) => setTimeout(resolve, ms));
349
+ }
350
+
351
+ /**
352
+ * Create ApiError from AxiosError
353
+ */
354
+ private createApiError(error: AxiosError): ApiError {
355
+ const status = error.response?.status || 0;
356
+ const problemDetail = this.extractProblemDetail(error);
357
+
358
+ return new ApiError(status, problemDetail, error);
359
+ }
360
+
361
+ /**
362
+ * Extract ProblemDetail from error response
363
+ */
364
+ private extractProblemDetail(error: AxiosError): ProblemDetail | null {
365
+ if (!error.response?.data) {
366
+ return null;
367
+ }
368
+
369
+ const data = error.response.data;
370
+
371
+ // Check if response matches ProblemDetail structure
372
+ if (
373
+ typeof data === 'object' &&
374
+ 'type' in data &&
375
+ 'title' in data &&
376
+ 'status' in data
377
+ ) {
378
+ return data as ProblemDetail;
379
+ }
380
+
381
+ return null;
382
+ }
383
+
384
+ /**
385
+ * HTTP Methods with proper typing
386
+ */
387
+
388
+ async get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
389
+ const response = await this.instance.get<T>(url, config);
390
+ return response.data;
391
+ }
392
+
393
+ async post<T = any>(
394
+ url: string,
395
+ data?: any,
396
+ config?: AxiosRequestConfig
397
+ ): Promise<T> {
398
+ const response = await this.instance.post<T>(url, data, config);
399
+ return response.data;
400
+ }
401
+
402
+ async put<T = any>(
403
+ url: string,
404
+ data?: any,
405
+ config?: AxiosRequestConfig
406
+ ): Promise<T> {
407
+ const response = await this.instance.put<T>(url, data, config);
408
+ return response.data;
409
+ }
410
+
411
+ async patch<T = any>(
412
+ url: string,
413
+ data?: any,
414
+ config?: AxiosRequestConfig
415
+ ): Promise<T> {
416
+ const response = await this.instance.patch<T>(url, data, config);
417
+ return response.data;
418
+ }
419
+
420
+ async delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
421
+ const response = await this.instance.delete<T>(url, config);
422
+ return response.data;
423
+ }
424
+
425
+ /**
426
+ * Get the underlying Axios instance for advanced usage
427
+ */
428
+ getAxiosInstance(): AxiosInstance {
429
+ return this.instance;
430
+ }
431
+ }
@@ -0,0 +1,166 @@
1
+ import { AxiosError, HttpStatusCode } from 'axios';
2
+ import { ZodError } from 'zod';
3
+ import { CredentialsSignin } from 'next-auth';
4
+
5
+ /**
6
+ * Spring Boot ProblemDetail structure
7
+ */
8
+ export interface ProblemDetail {
9
+ type: string;
10
+ title: string;
11
+ status: HttpStatusCode;
12
+ detail?: string;
13
+ instance?: string;
14
+ errors?: Record<string, unknown>;
15
+ [key: string]: unknown;
16
+ }
17
+
18
+ /**
19
+ * Custom error class for API errors
20
+ */
21
+ export class ApiError extends Error {
22
+ constructor(
23
+ public status: HttpStatusCode,
24
+ public problemDetail: ProblemDetail | null,
25
+ public originalError: Error,
26
+ message?: string
27
+ ) {
28
+ super(
29
+ message ||
30
+ problemDetail?.detail ||
31
+ problemDetail?.title ||
32
+ originalError?.message ||
33
+ 'An error occurred'
34
+ );
35
+ this.name = 'ApiError';
36
+ Object.setPrototypeOf(this, ApiError.prototype);
37
+ }
38
+
39
+ get isClientError(): boolean {
40
+ return this.status >= 400 && this.status < 500;
41
+ }
42
+
43
+ get isServerError(): boolean {
44
+ return this.status >= 500;
45
+ }
46
+
47
+ get isUnauthorized(): boolean {
48
+ return this.status === 401;
49
+ }
50
+
51
+ get isForbidden(): boolean {
52
+ return this.status === 403;
53
+ }
54
+
55
+ get isNotFound(): boolean {
56
+ return this.status === 404;
57
+ }
58
+
59
+ static builder<T>(): ApiErrorBuilder {
60
+ return new ApiErrorBuilder<T>();
61
+ }
62
+
63
+ static of(error: Error) {
64
+ if (error instanceof ApiError) {
65
+ return error;
66
+ }
67
+
68
+ return ApiError.builder().originalError(error).build();
69
+ }
70
+
71
+ /**
72
+ * Create ApiError from Zod validation error
73
+ */
74
+ static fromZodError(zodError: ZodError) {
75
+ const errors: Record<string, string> = {};
76
+
77
+ zodError.errors.forEach((error) => {
78
+ error.path.forEach((path) => {
79
+ errors[path] = error.message;
80
+ })
81
+ })
82
+
83
+ return ApiError.builder()
84
+ .originalError(zodError)
85
+ .status(HttpStatusCode.BadRequest)
86
+ .message('Validation error')
87
+ .problemDetail('errors', errors)
88
+ .build();
89
+ }
90
+ }
91
+
92
+ export class ApiErrorBuilder<
93
+ AdditionalProblemDetails = Record<string, unknown>
94
+ > {
95
+ private readonly _problemDetail: ProblemDetail;
96
+ private _status: HttpStatusCode;
97
+ private _originalError: Error;
98
+ private _message: string;
99
+
100
+ constructor() {
101
+ this._status = HttpStatusCode.InternalServerError;
102
+ this._problemDetail = {
103
+ status: this._status,
104
+ title: '',
105
+ type: 'about:blank',
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Set standard ProblemDetail fields
111
+ */
112
+ problemDetail<K extends keyof (ProblemDetail & AdditionalProblemDetails)>(
113
+ key: K,
114
+ value: K extends keyof ProblemDetail
115
+ ? ProblemDetail[K]
116
+ : K extends keyof AdditionalProblemDetails
117
+ ? AdditionalProblemDetails[K]
118
+ : unknown
119
+ ): ApiErrorBuilder<AdditionalProblemDetails> {
120
+ (this._problemDetail as any)[key] = value;
121
+ return this;
122
+ }
123
+
124
+ originalError(error: Error): ApiErrorBuilder<AdditionalProblemDetails> {
125
+ this._originalError = error;
126
+ this._problemDetail.title = error.name;
127
+
128
+ if (error instanceof AxiosError) {
129
+ this.status(error.status);
130
+ }
131
+ if (error instanceof ZodError) {
132
+ this.status(HttpStatusCode.BadRequest);
133
+ this.message('Validation error');
134
+ }
135
+ return this;
136
+ }
137
+
138
+ status(status: HttpStatusCode): ApiErrorBuilder<AdditionalProblemDetails> {
139
+ this._status = status;
140
+ this._problemDetail.status = status;
141
+ return this;
142
+ }
143
+
144
+ message(msg: string): ApiErrorBuilder<AdditionalProblemDetails> {
145
+ this._message = msg;
146
+ this._problemDetail.detail = msg;
147
+ return this;
148
+ }
149
+
150
+ build(): ApiError {
151
+ return new ApiError(
152
+ this._status,
153
+ this._problemDetail,
154
+ this._originalError,
155
+ this._message
156
+ );
157
+ }
158
+ }
159
+
160
+ export class CredentialsApiError extends CredentialsSignin {
161
+ constructor(public readonly problemDetail: ProblemDetail) {
162
+ super();
163
+ this.code = problemDetail.title;
164
+ this.message = problemDetail.detail;
165
+ }
166
+ }
@@ -0,0 +1,13 @@
1
+ import { ApiError } from '../error';
2
+
3
+ /**
4
+ * [api-response]
5
+ * next-feature@0.0.11-beta
6
+ * November 4th 2025, 6:37:27 pm
7
+ */
8
+ export interface ApiResponse<Response> {
9
+ success?: boolean;
10
+ message?: string;
11
+ error?: ApiError;
12
+ data: Response;
13
+ }