@xfilecom/xframe 0.1.36 → 0.1.37

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/defaults.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "backendCore": "^1.0.16",
3
- "frontCore": "^0.2.22"
2
+ "backendCore": "^1.0.17",
3
+ "frontCore": "^0.2.23"
4
4
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xfilecom/xframe",
3
- "version": "0.1.36",
3
+ "version": "0.1.37",
4
4
  "description": "Scaffold full-stack app: Nest + @xfilecom/backend-core, Vite/React + @xfilecom/front-core",
5
5
  "license": "UNLICENSED",
6
6
  "bin": {
@@ -49,7 +49,7 @@ export const healthEndpoint: EndpointDef = {
49
49
  export const appMetaEndpoint: EndpointDef = {
50
50
  method: 'GET',
51
51
  path: '/app-meta',
52
- post: unwrapResponseData,
52
+ /** 공통 `{ code, data, error }` 는 axios 인터셉터에서 `data` 로 치환 */
53
53
  showIndicator: false,
54
54
  timeoutMs: 10_000,
55
55
  };
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * Axios — baseURL, 로깅 인터셉터, 전역 훅(onRequestStart/End), Bearer
3
+ * HTTP 200/201 본문이 `{ code, data, error }` 이면 code 검사 후 data 로 치환 또는 토스트+reject
3
4
  */
4
5
 
5
6
  import axios, {
@@ -8,25 +9,43 @@ import axios, {
8
9
  type InternalAxiosRequestConfig,
9
10
  } from 'axios';
10
11
  import { sessionStore } from '../store/session-store';
12
+ import {
13
+ applyCommonResponseEnvelope,
14
+ CommonResponseRejectedError,
15
+ formatCommonErrorField,
16
+ isCommonResponsePayload,
17
+ } from './commonResponse';
11
18
 
12
19
  const LOG_PREFIX = '[xframe/http]';
13
20
 
14
21
  export type OnRequestStart = () => void;
15
22
  export type OnRequestEnd = () => void;
16
23
  export type OnError = (message: string, code?: string | number) => void;
24
+ export type OnCommonBusinessError = (message: string, appCode: number) => void;
17
25
 
18
26
  let onRequestStart: OnRequestStart = () => {};
19
27
  let onRequestEnd: OnRequestEnd = () => {};
20
28
  let onError: OnError = () => {};
29
+ let onCommonBusinessError: OnCommonBusinessError = () => {};
30
+
31
+ export { CommonResponseRejectedError } from './commonResponse';
32
+ export {
33
+ COMMON_RESPONSE_SUCCESS_CODE,
34
+ formatCommonErrorField,
35
+ isCommonResponsePayload,
36
+ } from './commonResponse';
21
37
 
22
38
  export function setApiHooks(hooks: {
23
39
  onRequestStart?: OnRequestStart;
24
40
  onRequestEnd?: OnRequestEnd;
25
41
  onError?: OnError;
42
+ /** HTTP 정상(2xx)인데 body.code !== 0 일 때 (토스트 등) */
43
+ onCommonBusinessError?: OnCommonBusinessError;
26
44
  }) {
27
45
  if (hooks.onRequestStart) onRequestStart = hooks.onRequestStart;
28
46
  if (hooks.onRequestEnd) onRequestEnd = hooks.onRequestEnd;
29
47
  if (hooks.onError) onError = hooks.onError;
48
+ if (hooks.onCommonBusinessError) onCommonBusinessError = hooks.onCommonBusinessError;
30
49
  }
31
50
 
32
51
  export const apiClient = axios.create({
@@ -104,28 +123,37 @@ apiClient.interceptors.response.use(
104
123
  const c = response.config as ExtConfig;
105
124
  if (!c.skipGlobalIndicator) onRequestEnd();
106
125
  logResponseSuccess(response);
126
+ try {
127
+ applyCommonResponseEnvelope(response, onCommonBusinessError);
128
+ } catch (e) {
129
+ return Promise.reject(e);
130
+ }
107
131
  return response;
108
132
  },
109
133
  (err: AxiosError) => {
110
134
  const c = err.config as ExtConfig | undefined;
111
135
  if (!c?.skipGlobalIndicator) onRequestEnd();
112
136
  logResponseError(err);
113
- const res = err?.response as
114
- | { data?: { message?: unknown; error?: unknown; code?: string | number }; status?: number }
115
- | undefined;
116
- const raw = res?.data?.message ?? res?.data?.error ?? (err as Error)?.message ?? 'Request failed';
137
+ const resData = err.response?.data;
138
+ let raw: unknown =
139
+ isCommonResponsePayload(resData) ? resData.error : undefined;
140
+ if (raw === undefined) {
141
+ raw =
142
+ (err.response?.data as { message?: unknown; error?: unknown } | undefined)?.message ??
143
+ (err.response?.data as { message?: unknown; error?: unknown } | undefined)?.error ??
144
+ (err as Error)?.message ??
145
+ 'Request failed';
146
+ }
117
147
  const message =
118
148
  typeof raw === 'string'
119
149
  ? raw
120
150
  : Array.isArray(raw)
121
151
  ? raw.map((x) => (typeof x === 'string' ? x : String(x))).join(', ')
122
- : typeof raw === 'object' &&
123
- raw != null &&
124
- 'message' in raw &&
125
- typeof (raw as { message: unknown }).message === 'string'
126
- ? (raw as { message: string }).message
127
- : JSON.stringify(raw);
128
- const code = res?.status ?? (res?.data?.code as string | number | undefined);
152
+ : formatCommonErrorField(raw);
153
+ const code =
154
+ err.response?.status ??
155
+ (isCommonResponsePayload(resData) ? resData.code : undefined) ??
156
+ (err.response?.data as { code?: string | number } | undefined)?.code;
129
157
  onError(message, code);
130
158
  return Promise.reject(err);
131
159
  },
@@ -0,0 +1,80 @@
1
+ /**
2
+ * 서버 공통 본문: HTTP 200/201 + body `{ code, data, error }`
3
+ * — 비즈니스 실패는 code !== 0 (HTTP는 정상).
4
+ */
5
+
6
+ import type { AxiosResponse } from 'axios';
7
+
8
+ /** 백엔드와 동일한 “성공” 코드 */
9
+ export const COMMON_RESPONSE_SUCCESS_CODE = 0 as const;
10
+
11
+ export class CommonResponseRejectedError extends Error {
12
+ readonly appCode: number;
13
+ readonly payload: unknown;
14
+
15
+ constructor(message: string, appCode: number, payload: unknown) {
16
+ super(message);
17
+ this.name = 'CommonResponseRejectedError';
18
+ this.appCode = appCode;
19
+ this.payload = payload;
20
+ }
21
+ }
22
+
23
+ export function isCommonResponsePayload(
24
+ body: unknown,
25
+ ): body is { code: number; data: unknown; error?: unknown } {
26
+ if (body === null || typeof body !== 'object') return false;
27
+ const b = body as Record<string, unknown>;
28
+ return typeof b.code === 'number' && 'data' in b;
29
+ }
30
+
31
+ function extractMessageString(value: unknown): string | null {
32
+ if (value == null) return null;
33
+ if (typeof value === 'string') return value;
34
+ if (Array.isArray(value)) {
35
+ const first = value.find((x) => typeof x === 'string');
36
+ return first ?? (value.map((x) => (typeof x === 'string' ? x : String(x))).join(', ') || null);
37
+ }
38
+ if (typeof value === 'object' && 'message' in value) {
39
+ const inner = (value as { message: unknown }).message;
40
+ return extractMessageString(inner);
41
+ }
42
+ return null;
43
+ }
44
+
45
+ /** `error` 필드를 사용자용 문구로 */
46
+ export function formatCommonErrorField(error: unknown): string {
47
+ const extracted = extractMessageString(error);
48
+ if (extracted) return extracted;
49
+ if (typeof error === 'string') return error;
50
+ if (error != null && typeof error === 'object') {
51
+ try {
52
+ return JSON.stringify(error);
53
+ } catch {
54
+ return 'Request failed';
55
+ }
56
+ }
57
+ return 'Request failed';
58
+ }
59
+
60
+ export type OnCommonBusinessError = (message: string, appCode: number) => void;
61
+
62
+ /**
63
+ * 성공 인터셉터에서 호출: 공통 래핑이면 code 검사 후 data 로 치환하거나 reject.
64
+ */
65
+ export function applyCommonResponseEnvelope(
66
+ response: AxiosResponse,
67
+ onBusinessError: OnCommonBusinessError,
68
+ ): unknown {
69
+ const body = response.data;
70
+ if (!isCommonResponsePayload(body)) {
71
+ return response;
72
+ }
73
+ if (body.code !== COMMON_RESPONSE_SUCCESS_CODE) {
74
+ const msg = formatCommonErrorField(body.error);
75
+ onBusinessError(msg, body.code);
76
+ throw new CommonResponseRejectedError(msg, body.code, body);
77
+ }
78
+ response.data = body.data;
79
+ return response;
80
+ }
@@ -1,9 +1,9 @@
1
- import type { CommonEnvelope, HealthData } from '../../types/api';
1
+ import type { HealthData } from '../../types/api';
2
2
  import type { ApiMethodConfig, ApiMethodStore } from '../types';
3
3
  import { healthEndpoint } from '@shared/endpoint';
4
4
 
5
5
  /** GET /health — params·body 없음, 스토어는 validate/post 에서 사용 가능 */
6
- export const healthMethod: ApiMethodConfig<void, CommonEnvelope<HealthData>, ApiMethodStore> = {
6
+ export const healthMethod: ApiMethodConfig<void, HealthData, ApiMethodStore> = {
7
7
  schema: {},
8
8
  endpoint: healthEndpoint,
9
9
  ui: {
@@ -10,7 +10,16 @@ export {
10
10
  useSessionStore,
11
11
  } from './context/StoreProvider';
12
12
 
13
- export { configureApi, getApiBaseUrl, setApiHooks, apiClient } from './api/client';
13
+ export {
14
+ configureApi,
15
+ getApiBaseUrl,
16
+ setApiHooks,
17
+ apiClient,
18
+ CommonResponseRejectedError,
19
+ COMMON_RESPONSE_SUCCESS_CODE,
20
+ formatCommonErrorField,
21
+ isCommonResponsePayload,
22
+ } from './api/client';
14
23
  export { sendRequest, type RequestTransportOptions } from './api/request';
15
24
  export type { EndpointDef, HttpMethod } from '@shared/endpoint';
16
25
  export { healthEndpoint, appMetaEndpoint, unwrapResponseData } from '@shared/endpoint';
@@ -1,8 +1,8 @@
1
1
  import { sendRequest } from '../api/request';
2
2
  import { healthEndpoint } from '@shared/endpoint';
3
- import type { CommonEnvelope, HealthData } from '../types/api';
3
+ import type { HealthData } from '../types/api';
4
4
 
5
5
  /** 직접 호출용 (토스트 없음) — UI 훅은 보통 이 함수 사용 */
6
- export async function fetchHealth(): Promise<CommonEnvelope<HealthData>> {
7
- return sendRequest<unknown, CommonEnvelope<HealthData>>(healthEndpoint, {});
6
+ export async function fetchHealth(): Promise<HealthData> {
7
+ return sendRequest<unknown, HealthData>(healthEndpoint, {});
8
8
  }
@@ -4,6 +4,11 @@
4
4
 
5
5
  import { makeAutoObservable, runInAction } from 'mobx';
6
6
  import { setApiHooks } from '../api/client';
7
+ import {
8
+ CommonResponseRejectedError,
9
+ formatCommonErrorField,
10
+ isCommonResponsePayload,
11
+ } from '../api/commonResponse';
7
12
  import { sendRequest } from '../api/request';
8
13
  import type {
9
14
  ApiMethodConfig,
@@ -53,8 +58,14 @@ function extractMessageString(value: unknown): string | null {
53
58
  }
54
59
 
55
60
  function getErrorMessage(e: unknown): string {
56
- const err = e as { response?: { data?: { message?: unknown; error?: unknown } }; message?: string };
57
- const raw = err?.response?.data?.message ?? err?.response?.data?.error ?? err?.message;
61
+ if (e instanceof CommonResponseRejectedError) return e.message;
62
+ const err = e as { response?: { data?: unknown }; message?: string };
63
+ const resData = err?.response?.data;
64
+ if (isCommonResponsePayload(resData)) {
65
+ return formatCommonErrorField(resData.error);
66
+ }
67
+ const d = resData as { message?: unknown; error?: unknown } | undefined;
68
+ const raw = d?.message ?? d?.error ?? err?.message;
58
69
  const extracted = extractMessageString(raw);
59
70
  if (extracted) return extracted;
60
71
  if (raw != null && typeof raw === 'string') return raw;
@@ -94,6 +105,9 @@ export class RootStore {
94
105
  runInAction(() => this._endRequest());
95
106
  },
96
107
  onError: () => {},
108
+ onCommonBusinessError: (message) => {
109
+ runInAction(() => this.addToast(message, 'error'));
110
+ },
97
111
  });
98
112
  }
99
113
 
@@ -225,7 +239,7 @@ export class RootStore {
225
239
  }
226
240
  return result;
227
241
  } catch (e) {
228
- if (showErrorToast) {
242
+ if (showErrorToast && !(e instanceof CommonResponseRejectedError)) {
229
243
  runInAction(() => this.addToast(getErrorMessage(e), 'error'));
230
244
  }
231
245
  throw e;
@@ -344,7 +358,9 @@ export class RootStore {
344
358
  return data;
345
359
  } catch (e) {
346
360
  runInAction(() => {
347
- if (showErrorToast) this.addToast(getErrorMessage(e), 'error');
361
+ if (showErrorToast && !(e instanceof CommonResponseRejectedError)) {
362
+ this.addToast(getErrorMessage(e), 'error');
363
+ }
348
364
  });
349
365
  throw e;
350
366
  } finally {