create-tigra 2.1.4 → 2.1.5

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-tigra",
3
- "version": "2.1.4",
3
+ "version": "2.1.5",
4
4
  "type": "module",
5
5
  "description": "Create a production-ready full-stack app with Next.js 16 + Fastify 5 + Prisma + Redis",
6
6
  "bin": {
@@ -14,6 +14,7 @@
14
14
  | Redis | Caching (frequently accessed data, lookups), rate limiting, background task signaling |
15
15
  | Zod | Runtime validation and type inference |
16
16
  | JWT (HS256/RS256) | Auth — minimal token payload (id, role), role-based access control |
17
+ | axios | Outbound HTTP client (external API calls) |
17
18
  | PM2 + Nginx | Production deployment (cluster mode) |
18
19
  | Jest / Vitest | Testing — deterministic, mock external APIs |
19
20
 
@@ -148,3 +149,66 @@ Collection Root (Bearer Token: {{accessToken}})
148
149
  - Never interpolate raw values into SQL — always use Prisma query builder
149
150
  - Rate limit public endpoints
150
151
  - Avoid N+1 queries — prefer joins or batched queries
152
+
153
+ ## Outbound HTTP
154
+
155
+ Use `httpClient` from `src/libs/http.ts` for ALL outbound HTTP calls. Never use native `fetch`, `node:http`, or a new `axios.create()` at the call site — the singleton provides consistent logging, 30s timeout, and automatic error conversion.
156
+
157
+ ### Import
158
+
159
+ ```typescript
160
+ import { httpClient } from '@libs/http.js';
161
+ ```
162
+
163
+ ### Wrapping pattern (service layer)
164
+
165
+ Outbound HTTP calls belong in the **service layer**. The interceptor converts `AxiosError` to `InternalError` automatically — services only deal with `AppError` subclasses:
166
+
167
+ ```typescript
168
+ class WeatherService {
169
+ async getCurrentWeather(city: string): Promise<WeatherData> {
170
+ const response = await httpClient.get<WeatherApiResponse>(
171
+ `https://api.weather.example.com/v1/current`,
172
+ { params: { q: city } },
173
+ );
174
+ return response.data.result;
175
+ }
176
+ }
177
+ export const weatherService = new WeatherService();
178
+ ```
179
+
180
+ ### Auth headers
181
+
182
+ Do NOT add auth headers to the `httpClient` singleton — it is shared. Pass per-request headers at the call site:
183
+
184
+ ```typescript
185
+ await httpClient.post(url, body, {
186
+ headers: { Authorization: `Bearer ${token}` },
187
+ });
188
+ ```
189
+
190
+ ### Fine-grained error mapping
191
+
192
+ The interceptor always throws `InternalError`. If you need a more specific error (e.g., a 404 from an external API should surface as `NotFoundError`), catch and rethrow:
193
+
194
+ ```typescript
195
+ try {
196
+ return await httpClient.get(url);
197
+ } catch {
198
+ throw new NotFoundError('External resource not found');
199
+ }
200
+ ```
201
+
202
+ ### Fixed-base-URL services
203
+
204
+ If a service always calls the same external API, create a private derived instance **inside the service file only** (never exported):
205
+
206
+ ```typescript
207
+ const apiClient = axios.create({ ...httpClient.defaults, baseURL: 'https://api.example.com/v2' });
208
+ ```
209
+
210
+ ### Rules
211
+
212
+ - Always use `httpClient` — never `fetch`, `node:http`, or inline `axios.create()`.
213
+ - Never add auth headers, cookies, or credentials to the singleton itself.
214
+ - Never log response bodies (may contain PII or secrets).
@@ -38,6 +38,7 @@
38
38
  "@fastify/static": "^9.0.0",
39
39
  "@prisma/client": "^6.19.2",
40
40
  "argon2": "^0.44.0",
41
+ "axios": "^1.7.9",
41
42
  "dotenv": "^16.4.7",
42
43
  "fastify": "^5.7.4",
43
44
  "fastify-type-provider-zod": "^6.1.0",
@@ -0,0 +1,414 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import type { InternalAxiosRequestConfig, AxiosResponse } from 'axios';
3
+ import { InternalError } from '@shared/errors/errors.js';
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Captured interceptor handlers (populated when http.ts is first imported)
7
+ // vi.hoisted() ensures these are available inside the vi.mock factory
8
+ // ---------------------------------------------------------------------------
9
+ const captured = vi.hoisted(() => ({
10
+ requestFulfilled: null as null | ((c: InternalAxiosRequestConfig) => InternalAxiosRequestConfig),
11
+ requestRejected: null as null | ((e: unknown) => never),
12
+ responseFulfilled: null as null | ((r: AxiosResponse) => AxiosResponse),
13
+ responseRejected: null as null | ((e: unknown) => never),
14
+ mockIsAxiosError: vi.fn<(e: unknown) => boolean>(),
15
+ }));
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Mock axios — captures interceptor handlers at registration time
19
+ // ---------------------------------------------------------------------------
20
+ vi.mock('axios', () => ({
21
+ default: {
22
+ create: vi.fn(() => ({
23
+ interceptors: {
24
+ request: {
25
+ use: vi.fn((onFulfilled: any, onRejected: any) => {
26
+ captured.requestFulfilled = onFulfilled;
27
+ captured.requestRejected = onRejected;
28
+ }),
29
+ },
30
+ response: {
31
+ use: vi.fn((onFulfilled: any, onRejected: any) => {
32
+ captured.responseFulfilled = onFulfilled;
33
+ captured.responseRejected = onRejected;
34
+ }),
35
+ },
36
+ },
37
+ })),
38
+ },
39
+ isAxiosError: captured.mockIsAxiosError,
40
+ }));
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Mock logger — prevents real Pino I/O in tests
44
+ // ---------------------------------------------------------------------------
45
+ vi.mock('@libs/logger.js', () => ({
46
+ logger: {
47
+ debug: vi.fn(),
48
+ error: vi.fn(),
49
+ },
50
+ }));
51
+
52
+ // Import AFTER mocks are registered
53
+ import { httpClient } from '../http.js';
54
+ import { logger } from '@libs/logger.js';
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Helpers
58
+ // ---------------------------------------------------------------------------
59
+ function makeConfig(overrides: Partial<InternalAxiosRequestConfig> = {}): InternalAxiosRequestConfig {
60
+ return { headers: {} as InternalAxiosRequestConfig['headers'], ...overrides };
61
+ }
62
+
63
+ function makeResponse(overrides: Partial<AxiosResponse> = {}): AxiosResponse {
64
+ return {
65
+ data: {},
66
+ status: 200,
67
+ statusText: 'OK',
68
+ headers: {},
69
+ config: makeConfig({ method: 'get', url: '/test' }),
70
+ ...overrides,
71
+ };
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Tests
76
+ // ---------------------------------------------------------------------------
77
+ describe('httpClient', () => {
78
+ beforeEach(() => {
79
+ vi.clearAllMocks();
80
+ captured.mockIsAxiosError.mockReturnValue(false);
81
+ });
82
+
83
+ // -------------------------------------------------------------------------
84
+ // Module contract
85
+ // -------------------------------------------------------------------------
86
+ describe('module contract', () => {
87
+ it('should be exported as a named export', () => {
88
+ expect(httpClient).toBeDefined();
89
+ });
90
+
91
+ it('should return the same singleton instance on re-import', async () => {
92
+ const { httpClient: httpClient2 } = await import('../http.js');
93
+ expect(httpClient2).toBe(httpClient);
94
+ });
95
+
96
+ it('should have registered exactly one request interceptor', () => {
97
+ expect(captured.requestFulfilled).not.toBeNull();
98
+ expect(captured.requestRejected).not.toBeNull();
99
+ });
100
+
101
+ it('should have registered exactly one response interceptor', () => {
102
+ expect(captured.responseFulfilled).not.toBeNull();
103
+ expect(captured.responseRejected).not.toBeNull();
104
+ });
105
+ });
106
+
107
+ // -------------------------------------------------------------------------
108
+ // Request interceptor — success path
109
+ // -------------------------------------------------------------------------
110
+ describe('request interceptor (success)', () => {
111
+ it('should return the config unchanged', () => {
112
+ const config = makeConfig({ method: 'get', url: '/users' });
113
+ const result = captured.requestFulfilled!(config);
114
+ expect(result).toBe(config);
115
+ });
116
+
117
+ it('should log outbound request at debug level', () => {
118
+ const config = makeConfig({ method: 'get', url: '/users', baseURL: 'https://api.example.com' });
119
+ captured.requestFulfilled!(config);
120
+ expect(logger.debug).toHaveBeenCalledOnce();
121
+ expect(logger.debug).toHaveBeenCalledWith(
122
+ { method: 'GET', url: '/users', baseURL: 'https://api.example.com' },
123
+ '[HTTP] Outbound request',
124
+ );
125
+ });
126
+
127
+ it('should uppercase the HTTP method in the log', () => {
128
+ captured.requestFulfilled!(makeConfig({ method: 'post', url: '/items' }));
129
+ expect(logger.debug).toHaveBeenCalledWith(
130
+ expect.objectContaining({ method: 'POST' }),
131
+ '[HTTP] Outbound request',
132
+ );
133
+ });
134
+
135
+ it('should handle undefined method without throwing', () => {
136
+ const config = makeConfig({ url: '/noop' });
137
+ expect(() => captured.requestFulfilled!(config)).not.toThrow();
138
+ expect(logger.debug).toHaveBeenCalledWith(
139
+ expect.objectContaining({ method: undefined }),
140
+ '[HTTP] Outbound request',
141
+ );
142
+ });
143
+
144
+ it('should not call logger.error on the success path', () => {
145
+ captured.requestFulfilled!(makeConfig({ method: 'get', url: '/ok' }));
146
+ expect(logger.error).not.toHaveBeenCalled();
147
+ });
148
+ });
149
+
150
+ // -------------------------------------------------------------------------
151
+ // Request interceptor — error path
152
+ // -------------------------------------------------------------------------
153
+ describe('request interceptor (error)', () => {
154
+ it('should throw an InternalError', () => {
155
+ expect(() => captured.requestRejected!(new Error('setup fail'))).toThrow(InternalError);
156
+ });
157
+
158
+ it('should throw with the correct message', () => {
159
+ expect(() => captured.requestRejected!(new Error('x'))).toThrow(
160
+ 'Outbound HTTP request could not be constructed',
161
+ );
162
+ });
163
+
164
+ it('should throw InternalError with statusCode 500', () => {
165
+ let thrown: unknown;
166
+ try {
167
+ captured.requestRejected!(new Error('x'));
168
+ } catch (e) {
169
+ thrown = e;
170
+ }
171
+ expect((thrown as InternalError).statusCode).toBe(500);
172
+ });
173
+
174
+ it('should throw InternalError with code INTERNAL_ERROR', () => {
175
+ let thrown: unknown;
176
+ try {
177
+ captured.requestRejected!(new Error('x'));
178
+ } catch (e) {
179
+ thrown = e;
180
+ }
181
+ expect((thrown as InternalError).code).toBe('INTERNAL_ERROR');
182
+ });
183
+
184
+ it('should log the error before throwing', () => {
185
+ const err = new Error('setup fail');
186
+ try {
187
+ captured.requestRejected!(err);
188
+ } catch {
189
+ // expected
190
+ }
191
+ expect(logger.error).toHaveBeenCalledWith({ err }, '[HTTP] Request setup failed');
192
+ });
193
+
194
+ it('should not call logger.debug on the error path', () => {
195
+ try {
196
+ captured.requestRejected!(new Error('x'));
197
+ } catch {
198
+ // expected
199
+ }
200
+ expect(logger.debug).not.toHaveBeenCalled();
201
+ });
202
+ });
203
+
204
+ // -------------------------------------------------------------------------
205
+ // Response interceptor — success path
206
+ // -------------------------------------------------------------------------
207
+ describe('response interceptor (success)', () => {
208
+ it('should return the response unchanged', () => {
209
+ const response = makeResponse({ status: 200, data: { id: 42 } });
210
+ const result = captured.responseFulfilled!(response);
211
+ expect(result).toBe(response);
212
+ });
213
+
214
+ it('should log the response at debug level', () => {
215
+ const response = makeResponse({
216
+ status: 201,
217
+ config: makeConfig({ method: 'post', url: '/items' }),
218
+ });
219
+ captured.responseFulfilled!(response);
220
+ expect(logger.debug).toHaveBeenCalledOnce();
221
+ expect(logger.debug).toHaveBeenCalledWith(
222
+ { method: 'POST', url: '/items', status: 201 },
223
+ '[HTTP] Outbound response',
224
+ );
225
+ });
226
+
227
+ it('should uppercase the HTTP method in the response log', () => {
228
+ const response = makeResponse({
229
+ config: makeConfig({ method: 'delete', url: '/items/1' }),
230
+ });
231
+ captured.responseFulfilled!(response);
232
+ expect(logger.debug).toHaveBeenCalledWith(
233
+ expect.objectContaining({ method: 'DELETE' }),
234
+ '[HTTP] Outbound response',
235
+ );
236
+ });
237
+
238
+ it('should not call logger.error on the success path', () => {
239
+ captured.responseFulfilled!(makeResponse());
240
+ expect(logger.error).not.toHaveBeenCalled();
241
+ });
242
+ });
243
+
244
+ // -------------------------------------------------------------------------
245
+ // Response interceptor — AxiosError path
246
+ // -------------------------------------------------------------------------
247
+ describe('response interceptor (AxiosError)', () => {
248
+ beforeEach(() => {
249
+ captured.mockIsAxiosError.mockReturnValue(true);
250
+ });
251
+
252
+ const buildAxiosError = (overrides: Record<string, unknown> = {}): object => ({
253
+ isAxiosError: true,
254
+ config: { method: 'get', url: '/failing', headers: {} },
255
+ response: { status: 503 },
256
+ message: 'Service Unavailable',
257
+ ...overrides,
258
+ });
259
+
260
+ it('should throw an InternalError', () => {
261
+ expect(() => captured.responseRejected!(buildAxiosError())).toThrow(InternalError);
262
+ });
263
+
264
+ it('should throw with the correct message', () => {
265
+ expect(() => captured.responseRejected!(buildAxiosError())).toThrow(
266
+ 'Outbound HTTP request failed',
267
+ );
268
+ });
269
+
270
+ it('should throw InternalError with statusCode 500', () => {
271
+ let thrown: unknown;
272
+ try {
273
+ captured.responseRejected!(buildAxiosError());
274
+ } catch (e) {
275
+ thrown = e;
276
+ }
277
+ expect((thrown as InternalError).statusCode).toBe(500);
278
+ });
279
+
280
+ it('should throw InternalError with code INTERNAL_ERROR', () => {
281
+ let thrown: unknown;
282
+ try {
283
+ captured.responseRejected!(buildAxiosError());
284
+ } catch (e) {
285
+ thrown = e;
286
+ }
287
+ expect((thrown as InternalError).code).toBe('INTERNAL_ERROR');
288
+ });
289
+
290
+ it('should log method, url, status, and message', () => {
291
+ try {
292
+ captured.responseRejected!(
293
+ buildAxiosError({
294
+ config: { method: 'post', url: '/upload', headers: {} },
295
+ response: { status: 413 },
296
+ message: 'Payload Too Large',
297
+ }),
298
+ );
299
+ } catch {
300
+ // expected
301
+ }
302
+ expect(logger.error).toHaveBeenCalledWith(
303
+ { method: 'POST', url: '/upload', status: 413, message: 'Payload Too Large' },
304
+ '[HTTP] Outbound request failed',
305
+ );
306
+ });
307
+
308
+ it('should uppercase method in error log', () => {
309
+ try {
310
+ captured.responseRejected!(
311
+ buildAxiosError({ config: { method: 'delete', url: '/x', headers: {} } }),
312
+ );
313
+ } catch {
314
+ // expected
315
+ }
316
+ expect(logger.error).toHaveBeenCalledWith(
317
+ expect.objectContaining({ method: 'DELETE' }),
318
+ '[HTTP] Outbound request failed',
319
+ );
320
+ });
321
+
322
+ it('should handle missing config/response gracefully', () => {
323
+ const minimalError = { isAxiosError: true, message: 'timeout' };
324
+ expect(() => captured.responseRejected!(minimalError)).toThrow(InternalError);
325
+ expect(logger.error).toHaveBeenCalledWith(
326
+ { method: undefined, url: undefined, status: undefined, message: 'timeout' },
327
+ '[HTTP] Outbound request failed',
328
+ );
329
+ });
330
+
331
+ it('should not call logger.debug on the error path', () => {
332
+ try {
333
+ captured.responseRejected!(buildAxiosError());
334
+ } catch {
335
+ // expected
336
+ }
337
+ expect(logger.debug).not.toHaveBeenCalled();
338
+ });
339
+ });
340
+
341
+ // -------------------------------------------------------------------------
342
+ // Response interceptor — non-AxiosError path
343
+ // -------------------------------------------------------------------------
344
+ describe('response interceptor (non-AxiosError)', () => {
345
+ beforeEach(() => {
346
+ captured.mockIsAxiosError.mockReturnValue(false);
347
+ });
348
+
349
+ it('should throw an InternalError for a plain Error', () => {
350
+ expect(() => captured.responseRejected!(new Error('network failure'))).toThrow(InternalError);
351
+ });
352
+
353
+ it('should throw with the correct message', () => {
354
+ expect(() => captured.responseRejected!(new Error('x'))).toThrow(
355
+ 'Outbound HTTP request failed',
356
+ );
357
+ });
358
+
359
+ it('should log with the generic message and err field', () => {
360
+ const err = new Error('network failure');
361
+ try {
362
+ captured.responseRejected!(err);
363
+ } catch {
364
+ // expected
365
+ }
366
+ expect(logger.error).toHaveBeenCalledWith({ err }, '[HTTP] Unexpected outbound error');
367
+ });
368
+
369
+ it('should throw InternalError for thrown string', () => {
370
+ expect(() => captured.responseRejected!('something broke')).toThrow(InternalError);
371
+ });
372
+
373
+ it('should throw InternalError for thrown null', () => {
374
+ expect(() => captured.responseRejected!(null)).toThrow(InternalError);
375
+ });
376
+
377
+ it('should not call logger.debug on the error path', () => {
378
+ try {
379
+ captured.responseRejected!(new Error('x'));
380
+ } catch {
381
+ // expected
382
+ }
383
+ expect(logger.debug).not.toHaveBeenCalled();
384
+ });
385
+ });
386
+
387
+ // -------------------------------------------------------------------------
388
+ // Security: sensitive data must NOT be logged
389
+ // -------------------------------------------------------------------------
390
+ describe('security: no sensitive data in logs', () => {
391
+ it('should not log request body in request interceptor', () => {
392
+ captured.requestFulfilled!(makeConfig({ method: 'post', url: '/login', data: { password: 'secret' } }));
393
+ const logCall = vi.mocked(logger.debug).mock.calls[0][0] as Record<string, unknown>;
394
+ expect(logCall).not.toHaveProperty('data');
395
+ expect(JSON.stringify(logCall)).not.toContain('secret');
396
+ });
397
+
398
+ it('should not log response body in response interceptor', () => {
399
+ captured.responseFulfilled!(makeResponse({ data: { token: 'bearer-xyz', password: 'hidden' } }));
400
+ const logCall = vi.mocked(logger.debug).mock.calls[0][0] as Record<string, unknown>;
401
+ expect(logCall).not.toHaveProperty('data');
402
+ expect(JSON.stringify(logCall)).not.toContain('bearer-xyz');
403
+ });
404
+
405
+ it('should not log request headers (may contain Authorization)', () => {
406
+ captured.requestFulfilled!(
407
+ makeConfig({ method: 'get', url: '/me', headers: { Authorization: 'Bearer token123' } as any }),
408
+ );
409
+ const logCall = vi.mocked(logger.debug).mock.calls[0][0] as Record<string, unknown>;
410
+ expect(logCall).not.toHaveProperty('headers');
411
+ expect(JSON.stringify(logCall)).not.toContain('token123');
412
+ });
413
+ });
414
+ });
@@ -0,0 +1,66 @@
1
+ import axios, {
2
+ type AxiosInstance,
3
+ type InternalAxiosRequestConfig,
4
+ type AxiosResponse,
5
+ isAxiosError,
6
+ } from 'axios';
7
+ import { logger } from '@libs/logger.js';
8
+ import { InternalError } from '@shared/errors/errors.js';
9
+
10
+ const TIMEOUT_MS = 30_000;
11
+
12
+ const httpClient: AxiosInstance = axios.create({
13
+ timeout: TIMEOUT_MS,
14
+ headers: {
15
+ 'Content-Type': 'application/json',
16
+ Accept: 'application/json',
17
+ },
18
+ });
19
+
20
+ // Request interceptor — log every outbound call
21
+ httpClient.interceptors.request.use(
22
+ (config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => {
23
+ logger.debug(
24
+ { method: config.method?.toUpperCase(), url: config.url, baseURL: config.baseURL },
25
+ '[HTTP] Outbound request',
26
+ );
27
+ return config;
28
+ },
29
+ (error: unknown): never => {
30
+ logger.error({ err: error }, '[HTTP] Request setup failed');
31
+ throw new InternalError('Outbound HTTP request could not be constructed');
32
+ },
33
+ );
34
+
35
+ // Response interceptor — log responses and convert AxiosError → InternalError
36
+ httpClient.interceptors.response.use(
37
+ (response: AxiosResponse): AxiosResponse => {
38
+ logger.debug(
39
+ {
40
+ method: response.config.method?.toUpperCase(),
41
+ url: response.config.url,
42
+ status: response.status,
43
+ },
44
+ '[HTTP] Outbound response',
45
+ );
46
+ return response;
47
+ },
48
+ (error: unknown): never => {
49
+ if (isAxiosError(error)) {
50
+ logger.error(
51
+ {
52
+ method: error.config?.method?.toUpperCase(),
53
+ url: error.config?.url,
54
+ status: error.response?.status,
55
+ message: error.message,
56
+ },
57
+ '[HTTP] Outbound request failed',
58
+ );
59
+ } else {
60
+ logger.error({ err: error }, '[HTTP] Unexpected outbound error');
61
+ }
62
+ throw new InternalError('Outbound HTTP request failed');
63
+ },
64
+ );
65
+
66
+ export { httpClient };