create-react-starter-kit 1.0.5 → 1.0.7
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/dist/cli-args.d.ts.map +1 -1
- package/dist/cli-args.js +2 -1
- package/dist/cli-args.js.map +1 -1
- package/dist/generator/dependencies.d.ts.map +1 -1
- package/dist/generator/dependencies.js +5 -0
- package/dist/generator/dependencies.js.map +1 -1
- package/dist/generator/index.d.ts.map +1 -1
- package/dist/generator/index.js +5 -0
- package/dist/generator/index.js.map +1 -1
- package/dist/prompts/index.d.ts.map +1 -1
- package/dist/prompts/index.js +8 -0
- package/dist/prompts/index.js.map +1 -1
- package/dist/templates/README.md.ejs +1 -1
- package/dist/templates/__when_apiLayer_axios__/src/services/api.ts.ejs +23 -0
- package/dist/templates/__when_apiLayer_axios__/src/services/http/client.ts.ejs +174 -0
- package/dist/templates/__when_apiLayer_axios__/src/services/http/errors.ts.ejs +81 -0
- package/dist/templates/__when_apiLayer_axios__/src/services/http/index.ts.ejs +5 -0
- package/dist/templates/__when_apiLayer_fetch__/src/services/api.ts.ejs +22 -0
- package/dist/templates/__when_apiLayer_fetch__/src/services/http/client.ts.ejs +195 -0
- package/dist/templates/__when_apiLayer_fetch__/src/services/http/errors.ts.ejs +81 -0
- package/dist/templates/__when_apiLayer_fetch__/src/services/http/index.ts.ejs +5 -0
- package/dist/templates/__when_buildTool_vite__/vite.config.ts.ejs +9 -1
- package/dist/templates/__when_buildTool_webpack__/webpack.config.js.ejs +1 -1
- package/dist/templates/__when_e2eTesting_cypress__/cypress.config.ts.ejs +2 -1
- package/dist/templates/__when_e2eTesting_playwright__/playwright.config.ts.ejs +4 -3
- package/dist/templates/__when_serverState_rtk-query__/src/store/postsApi.ts.ejs +28 -5
- package/dist/templates/__when_unitTesting_jest__/jest.config.json.ejs +8 -0
- package/dist/templates/__when_unitTesting_vitest__/tests/feedback.test.tsx.ejs +16 -0
- package/dist/templates/__when_unitTesting_vitest__/tests/posts.test.tsx.ejs +1 -3
- package/dist/templates/__when_unitTesting_vitest__/vitest.config.ts.ejs +31 -0
- package/dist/templates/docs/architecture.md.ejs +44 -25
- package/dist/templates/docs/best-practices.md.ejs +39 -3
- package/dist/templates/docs/examples/README.md.ejs +10 -10
- package/dist/templates/docs/examples/api.md.ejs +165 -8
- package/dist/templates/docs/examples/data-fetching.md.ejs +107 -6
- package/dist/templates/docs/examples/forms.md.ejs +66 -6
- package/dist/templates/docs/examples/state.md.ejs +50 -6
- package/dist/templates/docs/examples/styling.md.ejs +56 -8
- package/dist/templates/docs/examples/testing.md.ejs +63 -9
- package/dist/templates/docs/getting-started.md.ejs +63 -15
- package/dist/templates/docs/selected-stack.md.ejs +110 -8
- package/dist/templates/src/features/feedback/FeedbackForm.tsx.ejs +10 -0
- package/dist/templates/src/features/posts/PostsPanel.tsx.ejs +1 -1
- package/dist/templates/src/routes/PostsPage.tsx.ejs +7 -2
- package/dist/templates/src/types/index.ts.ejs +1 -1
- package/dist/templates/src/types/models.ts.ejs +18 -0
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/templates/README.md.ejs +1 -1
- package/templates/__when_apiLayer_axios__/src/services/api.ts.ejs +23 -0
- package/templates/__when_apiLayer_axios__/src/services/http/client.ts.ejs +174 -0
- package/templates/__when_apiLayer_axios__/src/services/http/errors.ts.ejs +81 -0
- package/templates/__when_apiLayer_axios__/src/services/http/index.ts.ejs +5 -0
- package/templates/__when_apiLayer_fetch__/src/services/api.ts.ejs +22 -0
- package/templates/__when_apiLayer_fetch__/src/services/http/client.ts.ejs +195 -0
- package/templates/__when_apiLayer_fetch__/src/services/http/errors.ts.ejs +81 -0
- package/templates/__when_apiLayer_fetch__/src/services/http/index.ts.ejs +5 -0
- package/templates/__when_buildTool_vite__/vite.config.ts.ejs +9 -1
- package/templates/__when_buildTool_webpack__/webpack.config.js.ejs +1 -1
- package/templates/__when_e2eTesting_cypress__/cypress.config.ts.ejs +2 -1
- package/templates/__when_e2eTesting_playwright__/playwright.config.ts.ejs +4 -3
- package/templates/__when_serverState_rtk-query__/src/store/postsApi.ts.ejs +28 -5
- package/templates/__when_unitTesting_jest__/jest.config.json.ejs +8 -0
- package/templates/__when_unitTesting_vitest__/tests/feedback.test.tsx.ejs +16 -0
- package/templates/__when_unitTesting_vitest__/tests/posts.test.tsx.ejs +1 -3
- package/templates/__when_unitTesting_vitest__/vitest.config.ts.ejs +31 -0
- package/templates/docs/architecture.md.ejs +44 -25
- package/templates/docs/best-practices.md.ejs +39 -3
- package/templates/docs/examples/README.md.ejs +10 -10
- package/templates/docs/examples/api.md.ejs +165 -8
- package/templates/docs/examples/data-fetching.md.ejs +107 -6
- package/templates/docs/examples/forms.md.ejs +66 -6
- package/templates/docs/examples/state.md.ejs +50 -6
- package/templates/docs/examples/styling.md.ejs +56 -8
- package/templates/docs/examples/testing.md.ejs +63 -9
- package/templates/docs/getting-started.md.ejs +63 -15
- package/templates/docs/selected-stack.md.ejs +110 -8
- package/templates/src/features/feedback/FeedbackForm.tsx.ejs +10 -0
- package/templates/src/features/posts/PostsPanel.tsx.ejs +1 -1
- package/templates/src/routes/PostsPage.tsx.ejs +7 -2
- package/templates/src/types/index.ts.ejs +1 -1
- package/templates/src/types/models.ts.ejs +18 -0
- package/dist/templates/src/services/api.ts.ejs +0 -84
- package/templates/src/services/api.ts.ejs +0 -84
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import axios<% if (isTypeScript) { %>, {
|
|
2
|
+
type AxiosError,
|
|
3
|
+
type AxiosInstance,
|
|
4
|
+
type AxiosRequestConfig,
|
|
5
|
+
type InternalAxiosRequestConfig,
|
|
6
|
+
}<% } %> from 'axios';
|
|
7
|
+
import { env } from '@/config/env';
|
|
8
|
+
import { ApiError, mapStatusToCode } from './errors';
|
|
9
|
+
|
|
10
|
+
const REQUEST_ID_HEADER = 'X-Request-Id';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Swap this for your real auth storage (cookie, memory store, secure storage).
|
|
14
|
+
* Keep token reads out of React components — the HTTP layer owns them.
|
|
15
|
+
*/
|
|
16
|
+
function getAccessToken()<% if (isTypeScript) { %>: string | null<% } %> {
|
|
17
|
+
if (typeof window === 'undefined') return null;
|
|
18
|
+
return window.localStorage.getItem('access_token');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function createRequestId()<% if (isTypeScript) { %>: string<% } %> {
|
|
22
|
+
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
|
23
|
+
return crypto.randomUUID();
|
|
24
|
+
}
|
|
25
|
+
return `req_${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function toApiError(error<% if (isTypeScript) { %>: unknown<% } %>)<% if (isTypeScript) { %>: ApiError<% } %> {
|
|
29
|
+
if (error instanceof ApiError) return error;
|
|
30
|
+
|
|
31
|
+
if (axios.isAxiosError(error)) {
|
|
32
|
+
const axiosError = error<% if (isTypeScript) { %> as AxiosError<{ message?: string; errors?: unknown }><% } %>;
|
|
33
|
+
const status = axiosError.response?.status;
|
|
34
|
+
const requestId =
|
|
35
|
+
(axiosError.config?.headers?.[REQUEST_ID_HEADER]<% if (isTypeScript) { %> as string | undefined<% } %>) ??
|
|
36
|
+
undefined;
|
|
37
|
+
|
|
38
|
+
if (axiosError.code === 'ECONNABORTED') {
|
|
39
|
+
return new ApiError({
|
|
40
|
+
message: 'Request timed out. Please try again.',
|
|
41
|
+
code: 'TIMEOUT',
|
|
42
|
+
requestId,
|
|
43
|
+
cause: error,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!axiosError.response) {
|
|
48
|
+
return new ApiError({
|
|
49
|
+
message: 'Network error. Check your connection and try again.',
|
|
50
|
+
code: 'NETWORK',
|
|
51
|
+
requestId,
|
|
52
|
+
cause: error,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const message =
|
|
57
|
+
axiosError.response.data?.message ||
|
|
58
|
+
axiosError.message ||
|
|
59
|
+
`Request failed with status ${status}`;
|
|
60
|
+
|
|
61
|
+
return new ApiError({
|
|
62
|
+
message,
|
|
63
|
+
status,
|
|
64
|
+
code: mapStatusToCode(status),
|
|
65
|
+
details: axiosError.response.data?.errors ?? axiosError.response.data,
|
|
66
|
+
requestId,
|
|
67
|
+
cause: error,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return new ApiError({
|
|
72
|
+
message: error instanceof Error ? error.message : 'Unexpected API error',
|
|
73
|
+
cause: error,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const apiClient<% if (isTypeScript) { %>: AxiosInstance<% } %> = axios.create({
|
|
78
|
+
baseURL: env.apiBaseUrl,
|
|
79
|
+
timeout: 15_000,
|
|
80
|
+
headers: {
|
|
81
|
+
Accept: 'application/json',
|
|
82
|
+
'Content-Type': 'application/json',
|
|
83
|
+
},
|
|
84
|
+
// Enable when your API uses cookie sessions across origins:
|
|
85
|
+
// withCredentials: true,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
apiClient.interceptors.request.use((config<% if (isTypeScript) { %>: InternalAxiosRequestConfig<% } %>) => {
|
|
89
|
+
const headers = config.headers ?? {};
|
|
90
|
+
const requestId = createRequestId();
|
|
91
|
+
headers[REQUEST_ID_HEADER] = requestId;
|
|
92
|
+
|
|
93
|
+
const token = getAccessToken();
|
|
94
|
+
if (token) {
|
|
95
|
+
headers.Authorization = `Bearer ${token}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
config.headers = headers;
|
|
99
|
+
return config;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
apiClient.interceptors.response.use(
|
|
103
|
+
(response) => response,
|
|
104
|
+
(error) => {
|
|
105
|
+
const apiError = toApiError(error);
|
|
106
|
+
|
|
107
|
+
// Central place to react to auth expiry (redirect, toast, refresh flow).
|
|
108
|
+
if (apiError.isUnauthorized && typeof window !== 'undefined') {
|
|
109
|
+
window.dispatchEvent(new CustomEvent('auth:unauthorized', { detail: apiError }));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (import.meta.env.MODE !== 'production') {
|
|
113
|
+
console.error('[api]', {
|
|
114
|
+
message: apiError.message,
|
|
115
|
+
status: apiError.status,
|
|
116
|
+
code: apiError.code,
|
|
117
|
+
requestId: apiError.requestId,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return Promise.reject(apiError);
|
|
122
|
+
},
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
<% if (isTypeScript) { -%>
|
|
126
|
+
export type RequestOptions = AxiosRequestConfig & {
|
|
127
|
+
signal?: AbortSignal;
|
|
128
|
+
};
|
|
129
|
+
<% } -%>
|
|
130
|
+
|
|
131
|
+
export async function apiGet<% if (isTypeScript) { %><T = unknown><% } %>(
|
|
132
|
+
path<% if (isTypeScript) { %>: string<% } %>,
|
|
133
|
+
options<% if (isTypeScript) { %>?: RequestOptions<% } %>,
|
|
134
|
+
)<% if (isTypeScript) { %>: Promise<T><% } %> {
|
|
135
|
+
const { data } = await apiClient.get<% if (isTypeScript) { %><T><% } %>(path, options);
|
|
136
|
+
return data;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function apiPost<% if (isTypeScript) { %><T = unknown, B = unknown><% } %>(
|
|
140
|
+
path<% if (isTypeScript) { %>: string<% } %>,
|
|
141
|
+
body<% if (isTypeScript) { %>?: B<% } %>,
|
|
142
|
+
options<% if (isTypeScript) { %>?: RequestOptions<% } %>,
|
|
143
|
+
)<% if (isTypeScript) { %>: Promise<T><% } %> {
|
|
144
|
+
const { data } = await apiClient.post<% if (isTypeScript) { %><T><% } %>(path, body, options);
|
|
145
|
+
return data;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function apiPut<% if (isTypeScript) { %><T = unknown, B = unknown><% } %>(
|
|
149
|
+
path<% if (isTypeScript) { %>: string<% } %>,
|
|
150
|
+
body<% if (isTypeScript) { %>?: B<% } %>,
|
|
151
|
+
options<% if (isTypeScript) { %>?: RequestOptions<% } %>,
|
|
152
|
+
)<% if (isTypeScript) { %>: Promise<T><% } %> {
|
|
153
|
+
const { data } = await apiClient.put<% if (isTypeScript) { %><T><% } %>(path, body, options);
|
|
154
|
+
return data;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function apiPatch<% if (isTypeScript) { %><T = unknown, B = unknown><% } %>(
|
|
158
|
+
path<% if (isTypeScript) { %>: string<% } %>,
|
|
159
|
+
body<% if (isTypeScript) { %>?: B<% } %>,
|
|
160
|
+
options<% if (isTypeScript) { %>?: RequestOptions<% } %>,
|
|
161
|
+
)<% if (isTypeScript) { %>: Promise<T><% } %> {
|
|
162
|
+
const { data } = await apiClient.patch<% if (isTypeScript) { %><T><% } %>(path, body, options);
|
|
163
|
+
return data;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function apiDelete<% if (isTypeScript) { %><T = unknown><% } %>(
|
|
167
|
+
path<% if (isTypeScript) { %>: string<% } %>,
|
|
168
|
+
options<% if (isTypeScript) { %>?: RequestOptions<% } %>,
|
|
169
|
+
)<% if (isTypeScript) { %>: Promise<T><% } %> {
|
|
170
|
+
const { data } = await apiClient.delete<% if (isTypeScript) { %><T><% } %>(path, options);
|
|
171
|
+
return data;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export { ApiError };
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
<% if (isTypeScript) { -%>
|
|
2
|
+
export type ApiErrorCode =
|
|
3
|
+
| 'NETWORK'
|
|
4
|
+
| 'TIMEOUT'
|
|
5
|
+
| 'UNAUTHORIZED'
|
|
6
|
+
| 'FORBIDDEN'
|
|
7
|
+
| 'NOT_FOUND'
|
|
8
|
+
| 'VALIDATION'
|
|
9
|
+
| 'SERVER'
|
|
10
|
+
| 'UNKNOWN';
|
|
11
|
+
|
|
12
|
+
export class ApiError extends Error {
|
|
13
|
+
readonly status?: number;
|
|
14
|
+
readonly code: ApiErrorCode;
|
|
15
|
+
readonly details?: unknown;
|
|
16
|
+
readonly requestId?: string;
|
|
17
|
+
|
|
18
|
+
constructor(options: {
|
|
19
|
+
message: string;
|
|
20
|
+
status?: number;
|
|
21
|
+
code?: ApiErrorCode;
|
|
22
|
+
details?: unknown;
|
|
23
|
+
requestId?: string;
|
|
24
|
+
cause?: unknown;
|
|
25
|
+
}) {
|
|
26
|
+
super(options.message, options.cause ? { cause: options.cause } : undefined);
|
|
27
|
+
this.name = 'ApiError';
|
|
28
|
+
this.status = options.status;
|
|
29
|
+
this.code = options.code ?? 'UNKNOWN';
|
|
30
|
+
this.details = options.details;
|
|
31
|
+
this.requestId = options.requestId;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get isUnauthorized() {
|
|
35
|
+
return this.status === 401 || this.code === 'UNAUTHORIZED';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get isRetryable() {
|
|
39
|
+
return this.code === 'NETWORK' || this.code === 'TIMEOUT' || this.code === 'SERVER';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function mapStatusToCode(status?: number): ApiErrorCode {
|
|
44
|
+
if (!status) return 'NETWORK';
|
|
45
|
+
if (status === 401) return 'UNAUTHORIZED';
|
|
46
|
+
if (status === 403) return 'FORBIDDEN';
|
|
47
|
+
if (status === 404) return 'NOT_FOUND';
|
|
48
|
+
if (status === 422 || status === 400) return 'VALIDATION';
|
|
49
|
+
if (status >= 500) return 'SERVER';
|
|
50
|
+
return 'UNKNOWN';
|
|
51
|
+
}
|
|
52
|
+
<% } else { -%>
|
|
53
|
+
export class ApiError extends Error {
|
|
54
|
+
constructor(options) {
|
|
55
|
+
super(options.message, options.cause ? { cause: options.cause } : undefined);
|
|
56
|
+
this.name = 'ApiError';
|
|
57
|
+
this.status = options.status;
|
|
58
|
+
this.code = options.code ?? 'UNKNOWN';
|
|
59
|
+
this.details = options.details;
|
|
60
|
+
this.requestId = options.requestId;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
get isUnauthorized() {
|
|
64
|
+
return this.status === 401 || this.code === 'UNAUTHORIZED';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
get isRetryable() {
|
|
68
|
+
return this.code === 'NETWORK' || this.code === 'TIMEOUT' || this.code === 'SERVER';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function mapStatusToCode(status) {
|
|
73
|
+
if (!status) return 'NETWORK';
|
|
74
|
+
if (status === 401) return 'UNAUTHORIZED';
|
|
75
|
+
if (status === 403) return 'FORBIDDEN';
|
|
76
|
+
if (status === 404) return 'NOT_FOUND';
|
|
77
|
+
if (status === 422 || status === 400) return 'VALIDATION';
|
|
78
|
+
if (status >= 500) return 'SERVER';
|
|
79
|
+
return 'UNKNOWN';
|
|
80
|
+
}
|
|
81
|
+
<% } -%>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { apiGet, apiPost } from '@/services/http';
|
|
2
|
+
<% if (isTypeScript) { -%>
|
|
3
|
+
import type { FeedbackPayload, Post } from '@/types/models';
|
|
4
|
+
<% } -%>
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Domain API methods — keep React components free of raw URLs and HTTP details.
|
|
8
|
+
*/
|
|
9
|
+
export async function fetchPosts(signal<% if (isTypeScript) { %>?: AbortSignal<% } %>) {
|
|
10
|
+
return apiGet<% if (isTypeScript) { %><Post[]><% } %>('/posts?_limit=5', { signal });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function createFeedback(
|
|
14
|
+
payload<% if (isTypeScript) { %>: FeedbackPayload<% } %>,
|
|
15
|
+
signal<% if (isTypeScript) { %>?: AbortSignal<% } %>,
|
|
16
|
+
) {
|
|
17
|
+
return apiPost('/posts', payload, { signal });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
<% if (isTypeScript) { -%>
|
|
21
|
+
export type { FeedbackPayload, Post } from '@/types/models';
|
|
22
|
+
<% } -%>
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { env } from '@/config/env';
|
|
2
|
+
import { ApiError, mapStatusToCode } from './errors';
|
|
3
|
+
|
|
4
|
+
const REQUEST_ID_HEADER = 'X-Request-Id';
|
|
5
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
6
|
+
|
|
7
|
+
<% if (isTypeScript) { -%>
|
|
8
|
+
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
9
|
+
|
|
10
|
+
export interface RequestOptions extends Omit<RequestInit, 'method' | 'body'> {
|
|
11
|
+
method?: HttpMethod;
|
|
12
|
+
body?: unknown;
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
/** Skip JSON Content-Type / body serialization when sending FormData, etc. */
|
|
15
|
+
rawBody?: boolean;
|
|
16
|
+
}
|
|
17
|
+
<% } -%>
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Swap this for your real auth storage (cookie, memory store, secure storage).
|
|
21
|
+
*/
|
|
22
|
+
function getAccessToken()<% if (isTypeScript) { %>: string | null<% } %> {
|
|
23
|
+
if (typeof window === 'undefined') return null;
|
|
24
|
+
return window.localStorage.getItem('access_token');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function createRequestId()<% if (isTypeScript) { %>: string<% } %> {
|
|
28
|
+
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
|
29
|
+
return crypto.randomUUID();
|
|
30
|
+
}
|
|
31
|
+
return `req_${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function parseErrorBody(response<% if (isTypeScript) { %>: Response<% } %>)<% if (isTypeScript) { %>: Promise<unknown><% } %> {
|
|
35
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
36
|
+
if (contentType.includes('application/json')) {
|
|
37
|
+
try {
|
|
38
|
+
return await response.json();
|
|
39
|
+
} catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
return await response.text();
|
|
45
|
+
} catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Shared fetch wrapper with auth header, request IDs, timeout, and normalized errors.
|
|
52
|
+
* Prefer domain helpers in `services/api` over calling this from components.
|
|
53
|
+
*/
|
|
54
|
+
export async function request<% if (isTypeScript) { %><T = unknown><% } %>(
|
|
55
|
+
path<% if (isTypeScript) { %>: string<% } %>,
|
|
56
|
+
options<% if (isTypeScript) { %>: RequestOptions = {}<% } else { %> = {}<% } %>,
|
|
57
|
+
)<% if (isTypeScript) { %>: Promise<T><% } %> {
|
|
58
|
+
const {
|
|
59
|
+
method = 'GET',
|
|
60
|
+
body,
|
|
61
|
+
headers: initHeaders,
|
|
62
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
63
|
+
rawBody = false,
|
|
64
|
+
signal: userSignal,
|
|
65
|
+
...rest
|
|
66
|
+
} = options;
|
|
67
|
+
|
|
68
|
+
const requestId = createRequestId();
|
|
69
|
+
const headers = new Headers(initHeaders);
|
|
70
|
+
headers.set(REQUEST_ID_HEADER, requestId);
|
|
71
|
+
headers.set('Accept', 'application/json');
|
|
72
|
+
|
|
73
|
+
const token = getAccessToken();
|
|
74
|
+
if (token) {
|
|
75
|
+
headers.set('Authorization', `Bearer ${token}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let payload<% if (isTypeScript) { %>: BodyInit | undefined<% } %>;
|
|
79
|
+
if (body !== undefined && body !== null) {
|
|
80
|
+
if (rawBody) {
|
|
81
|
+
payload = body<% if (isTypeScript) { %> as BodyInit<% } %>;
|
|
82
|
+
} else {
|
|
83
|
+
headers.set('Content-Type', 'application/json');
|
|
84
|
+
payload = JSON.stringify(body);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const controller = new AbortController();
|
|
89
|
+
const onAbort = () => controller.abort();
|
|
90
|
+
if (userSignal) {
|
|
91
|
+
if (userSignal.aborted) controller.abort();
|
|
92
|
+
else userSignal.addEventListener('abort', onAbort, { once: true });
|
|
93
|
+
}
|
|
94
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const response = await fetch(`${env.apiBaseUrl}${path}`, {
|
|
98
|
+
...rest,
|
|
99
|
+
method,
|
|
100
|
+
headers,
|
|
101
|
+
body: payload,
|
|
102
|
+
signal: controller.signal,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
if (!response.ok) {
|
|
106
|
+
const details = await parseErrorBody(response);
|
|
107
|
+
const message =
|
|
108
|
+
details && typeof details === 'object' && 'message' in details && typeof details.message === 'string'
|
|
109
|
+
? details.message
|
|
110
|
+
: `Request failed with status ${response.status}`;
|
|
111
|
+
|
|
112
|
+
const apiError = new ApiError({
|
|
113
|
+
message,
|
|
114
|
+
status: response.status,
|
|
115
|
+
code: mapStatusToCode(response.status),
|
|
116
|
+
details,
|
|
117
|
+
requestId,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
if (apiError.isUnauthorized && typeof window !== 'undefined') {
|
|
121
|
+
window.dispatchEvent(new CustomEvent('auth:unauthorized', { detail: apiError }));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
throw apiError;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (response.status === 204) {
|
|
128
|
+
return undefined<% if (isTypeScript) { %> as T<% } %>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return (await response.json())<% if (isTypeScript) { %> as T<% } %>;
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (error instanceof ApiError) throw error;
|
|
134
|
+
|
|
135
|
+
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
136
|
+
const timedOut = !userSignal?.aborted;
|
|
137
|
+
throw new ApiError({
|
|
138
|
+
message: timedOut ? 'Request timed out. Please try again.' : 'Request was cancelled.',
|
|
139
|
+
code: timedOut ? 'TIMEOUT' : 'NETWORK',
|
|
140
|
+
requestId,
|
|
141
|
+
cause: error,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
throw new ApiError({
|
|
146
|
+
message: error instanceof Error ? error.message : 'Network error. Check your connection and try again.',
|
|
147
|
+
code: 'NETWORK',
|
|
148
|
+
requestId,
|
|
149
|
+
cause: error,
|
|
150
|
+
});
|
|
151
|
+
} finally {
|
|
152
|
+
clearTimeout(timeoutId);
|
|
153
|
+
userSignal?.removeEventListener('abort', onAbort);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function apiGet<% if (isTypeScript) { %><T = unknown><% } %>(
|
|
158
|
+
path<% if (isTypeScript) { %>: string<% } %>,
|
|
159
|
+
options<% if (isTypeScript) { %>?: Omit<RequestOptions, 'method' | 'body'><% } %>,
|
|
160
|
+
)<% if (isTypeScript) { %>: Promise<T><% } %> {
|
|
161
|
+
return request<% if (isTypeScript) { %><T><% } %>(path, { ...options, method: 'GET' });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function apiPost<% if (isTypeScript) { %><T = unknown, B = unknown><% } %>(
|
|
165
|
+
path<% if (isTypeScript) { %>: string<% } %>,
|
|
166
|
+
body<% if (isTypeScript) { %>?: B<% } %>,
|
|
167
|
+
options<% if (isTypeScript) { %>?: Omit<RequestOptions, 'method' | 'body'><% } %>,
|
|
168
|
+
)<% if (isTypeScript) { %>: Promise<T><% } %> {
|
|
169
|
+
return request<% if (isTypeScript) { %><T><% } %>(path, { ...options, method: 'POST', body });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function apiPut<% if (isTypeScript) { %><T = unknown, B = unknown><% } %>(
|
|
173
|
+
path<% if (isTypeScript) { %>: string<% } %>,
|
|
174
|
+
body<% if (isTypeScript) { %>?: B<% } %>,
|
|
175
|
+
options<% if (isTypeScript) { %>?: Omit<RequestOptions, 'method' | 'body'><% } %>,
|
|
176
|
+
)<% if (isTypeScript) { %>: Promise<T><% } %> {
|
|
177
|
+
return request<% if (isTypeScript) { %><T><% } %>(path, { ...options, method: 'PUT', body });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function apiPatch<% if (isTypeScript) { %><T = unknown, B = unknown><% } %>(
|
|
181
|
+
path<% if (isTypeScript) { %>: string<% } %>,
|
|
182
|
+
body<% if (isTypeScript) { %>?: B<% } %>,
|
|
183
|
+
options<% if (isTypeScript) { %>?: Omit<RequestOptions, 'method' | 'body'><% } %>,
|
|
184
|
+
)<% if (isTypeScript) { %>: Promise<T><% } %> {
|
|
185
|
+
return request<% if (isTypeScript) { %><T><% } %>(path, { ...options, method: 'PATCH', body });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function apiDelete<% if (isTypeScript) { %><T = unknown><% } %>(
|
|
189
|
+
path<% if (isTypeScript) { %>: string<% } %>,
|
|
190
|
+
options<% if (isTypeScript) { %>?: Omit<RequestOptions, 'method' | 'body'><% } %>,
|
|
191
|
+
)<% if (isTypeScript) { %>: Promise<T><% } %> {
|
|
192
|
+
return request<% if (isTypeScript) { %><T><% } %>(path, { ...options, method: 'DELETE' });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export { ApiError };
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
<% if (isTypeScript) { -%>
|
|
2
|
+
export type ApiErrorCode =
|
|
3
|
+
| 'NETWORK'
|
|
4
|
+
| 'TIMEOUT'
|
|
5
|
+
| 'UNAUTHORIZED'
|
|
6
|
+
| 'FORBIDDEN'
|
|
7
|
+
| 'NOT_FOUND'
|
|
8
|
+
| 'VALIDATION'
|
|
9
|
+
| 'SERVER'
|
|
10
|
+
| 'UNKNOWN';
|
|
11
|
+
|
|
12
|
+
export class ApiError extends Error {
|
|
13
|
+
readonly status?: number;
|
|
14
|
+
readonly code: ApiErrorCode;
|
|
15
|
+
readonly details?: unknown;
|
|
16
|
+
readonly requestId?: string;
|
|
17
|
+
|
|
18
|
+
constructor(options: {
|
|
19
|
+
message: string;
|
|
20
|
+
status?: number;
|
|
21
|
+
code?: ApiErrorCode;
|
|
22
|
+
details?: unknown;
|
|
23
|
+
requestId?: string;
|
|
24
|
+
cause?: unknown;
|
|
25
|
+
}) {
|
|
26
|
+
super(options.message, options.cause ? { cause: options.cause } : undefined);
|
|
27
|
+
this.name = 'ApiError';
|
|
28
|
+
this.status = options.status;
|
|
29
|
+
this.code = options.code ?? 'UNKNOWN';
|
|
30
|
+
this.details = options.details;
|
|
31
|
+
this.requestId = options.requestId;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get isUnauthorized() {
|
|
35
|
+
return this.status === 401 || this.code === 'UNAUTHORIZED';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get isRetryable() {
|
|
39
|
+
return this.code === 'NETWORK' || this.code === 'TIMEOUT' || this.code === 'SERVER';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function mapStatusToCode(status?: number): ApiErrorCode {
|
|
44
|
+
if (!status) return 'NETWORK';
|
|
45
|
+
if (status === 401) return 'UNAUTHORIZED';
|
|
46
|
+
if (status === 403) return 'FORBIDDEN';
|
|
47
|
+
if (status === 404) return 'NOT_FOUND';
|
|
48
|
+
if (status === 422 || status === 400) return 'VALIDATION';
|
|
49
|
+
if (status >= 500) return 'SERVER';
|
|
50
|
+
return 'UNKNOWN';
|
|
51
|
+
}
|
|
52
|
+
<% } else { -%>
|
|
53
|
+
export class ApiError extends Error {
|
|
54
|
+
constructor(options) {
|
|
55
|
+
super(options.message, options.cause ? { cause: options.cause } : undefined);
|
|
56
|
+
this.name = 'ApiError';
|
|
57
|
+
this.status = options.status;
|
|
58
|
+
this.code = options.code ?? 'UNKNOWN';
|
|
59
|
+
this.details = options.details;
|
|
60
|
+
this.requestId = options.requestId;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
get isUnauthorized() {
|
|
64
|
+
return this.status === 401 || this.code === 'UNAUTHORIZED';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
get isRetryable() {
|
|
68
|
+
return this.code === 'NETWORK' || this.code === 'TIMEOUT' || this.code === 'SERVER';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function mapStatusToCode(status) {
|
|
73
|
+
if (!status) return 'NETWORK';
|
|
74
|
+
if (status === 401) return 'UNAUTHORIZED';
|
|
75
|
+
if (status === 403) return 'FORBIDDEN';
|
|
76
|
+
if (status === 404) return 'NOT_FOUND';
|
|
77
|
+
if (status === 422 || status === 400) return 'VALIDATION';
|
|
78
|
+
if (status >= 500) return 'SERVER';
|
|
79
|
+
return 'UNKNOWN';
|
|
80
|
+
}
|
|
81
|
+
<% } -%>
|
|
@@ -28,7 +28,15 @@ export default mergeConfig(
|
|
|
28
28
|
globals: true,
|
|
29
29
|
environment: 'jsdom',
|
|
30
30
|
setupFiles: './tests/setup.<%= ext %>',
|
|
31
|
-
|
|
31
|
+
include: ['tests/**/*.{test,spec}.{<%= isTypeScript ? 'ts,tsx' : 'js,jsx' %>}'],
|
|
32
|
+
exclude: [
|
|
33
|
+
'**/node_modules/**',
|
|
34
|
+
'**/dist/**',
|
|
35
|
+
'**/build/**',
|
|
36
|
+
'**/tests/e2e/**',
|
|
37
|
+
'**/e2e/**',
|
|
38
|
+
'**/*.e2e.*',
|
|
39
|
+
],
|
|
32
40
|
},
|
|
33
41
|
}),
|
|
34
42
|
);
|
|
@@ -3,6 +3,7 @@ import { defineConfig, devices } from '@playwright/test';
|
|
|
3
3
|
/**
|
|
4
4
|
* Run once before first e2e: `<%= packageManager === 'npm' ? 'npm run' : packageManager %> test:e2e:install`
|
|
5
5
|
*/
|
|
6
|
+
<% const e2ePort = buildTool === 'webpack' ? 3000 : 5173; -%>
|
|
6
7
|
export default defineConfig({
|
|
7
8
|
testDir: './tests/e2e',
|
|
8
9
|
fullyParallel: true,
|
|
@@ -10,7 +11,7 @@ export default defineConfig({
|
|
|
10
11
|
retries: process.env.CI ? 1 : 0,
|
|
11
12
|
reporter: process.env.CI ? 'github' : 'list',
|
|
12
13
|
use: {
|
|
13
|
-
baseURL: 'http://127.0.0.1
|
|
14
|
+
baseURL: 'http://127.0.0.1:<%= e2ePort %>',
|
|
14
15
|
trace: 'on-first-retry',
|
|
15
16
|
screenshot: 'only-on-failure',
|
|
16
17
|
},
|
|
@@ -19,13 +20,13 @@ export default defineConfig({
|
|
|
19
20
|
'<%= buildTool === 'vite'
|
|
20
21
|
? 'npx vite --host 127.0.0.1 --port 5173'
|
|
21
22
|
: buildTool === 'webpack'
|
|
22
|
-
? 'npx webpack serve --mode development --port
|
|
23
|
+
? 'npx webpack serve --mode development --port 3000'
|
|
23
24
|
: packageManager === 'npm'
|
|
24
25
|
? 'npm run dev'
|
|
25
26
|
: packageManager === 'yarn'
|
|
26
27
|
? 'yarn dev'
|
|
27
28
|
: 'pnpm dev' %>',
|
|
28
|
-
url: 'http://127.0.0.1
|
|
29
|
+
url: 'http://127.0.0.1:<%= e2ePort %>',
|
|
29
30
|
reuseExistingServer: !process.env.CI,
|
|
30
31
|
timeout: 120_000,
|
|
31
32
|
},
|
|
@@ -1,19 +1,42 @@
|
|
|
1
1
|
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
|
|
2
2
|
<% if (isTypeScript) { -%>
|
|
3
|
-
import type { Post } from '@/
|
|
3
|
+
import type { FeedbackPayload, Post } from '@/types/models';
|
|
4
4
|
<% } -%>
|
|
5
5
|
import { env } from '@/config/env';
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
/**
|
|
8
|
+
* RTK Query owns HTTP for this stack — no separate Axios/Fetch client is generated.
|
|
9
|
+
* Use `prepareHeaders` for auth and `tagTypes` for cache invalidation.
|
|
10
|
+
*/
|
|
9
11
|
export const postsApi = createApi({
|
|
10
12
|
reducerPath: 'postsApi',
|
|
11
|
-
baseQuery: fetchBaseQuery({
|
|
13
|
+
baseQuery: fetchBaseQuery({
|
|
14
|
+
baseUrl: env.apiBaseUrl,
|
|
15
|
+
prepareHeaders: (headers) => {
|
|
16
|
+
const token =
|
|
17
|
+
typeof window !== 'undefined' ? window.localStorage.getItem('access_token') : null;
|
|
18
|
+
if (token) {
|
|
19
|
+
headers.set('Authorization', `Bearer ${token}`);
|
|
20
|
+
}
|
|
21
|
+
headers.set('Accept', 'application/json');
|
|
22
|
+
return headers;
|
|
23
|
+
},
|
|
24
|
+
}),
|
|
25
|
+
tagTypes: ['Posts'],
|
|
12
26
|
endpoints: (builder) => ({
|
|
13
27
|
getPosts: builder.query<% if (isTypeScript) { %><Post[], void><% } %>({
|
|
14
28
|
query: () => '/posts?_limit=5',
|
|
29
|
+
providesTags: ['Posts'],
|
|
30
|
+
}),
|
|
31
|
+
createFeedback: builder.mutation<% if (isTypeScript) { %><{ id: number }, FeedbackPayload><% } %>({
|
|
32
|
+
query: (body) => ({
|
|
33
|
+
url: '/posts',
|
|
34
|
+
method: 'POST',
|
|
35
|
+
body,
|
|
36
|
+
}),
|
|
37
|
+
invalidatesTags: ['Posts'],
|
|
15
38
|
}),
|
|
16
39
|
}),
|
|
17
40
|
});
|
|
18
41
|
|
|
19
|
-
export const { useGetPostsQuery } = postsApi;
|
|
42
|
+
export const { useGetPostsQuery, useCreateFeedbackMutation } = postsApi;
|