@iyulab/enterprise 0.11.0 → 0.12.0
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/CHANGELOG.md +31 -0
- package/README.md +35 -3
- package/dist/data/ODataService.d.ts +17 -1
- package/dist/icons.d.ts +1 -1
- package/dist/index.js +38 -8
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.12.0
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **`ApiError.details`** — OData v4 error envelopes carry per-field validation
|
|
8
|
+
errors in `error.details`, and `ODataService` already parsed that envelope to
|
|
9
|
+
build `ApiError.message`, but discarded the rest of the same parse. `ApiError`
|
|
10
|
+
now also carries `details` (`ApiErrorDetail[] | undefined`), so a caller can
|
|
11
|
+
bind server-side validation failures to the fields that produced them instead
|
|
12
|
+
of showing one flattened string. Backward compatible: callers that never read
|
|
13
|
+
`.details` are unaffected.
|
|
14
|
+
- Per the OData JSON Format v4.0 spec each detail entry MUST have `code` and
|
|
15
|
+
`message` and MAY have `target`, so `target` is optional on `ApiErrorDetail`
|
|
16
|
+
and entries that lack a string `code`/`message` are dropped; when nothing
|
|
17
|
+
usable remains `details` is `undefined` rather than an empty array.
|
|
18
|
+
- **`formatError(info)` now receives `details`** — the callback that shapes the
|
|
19
|
+
user-facing message previously got only `rawMessage` and the raw `body`, so an
|
|
20
|
+
app wanting to summarize per-field failures had to re-dig and re-validate the
|
|
21
|
+
same `error.details` the service had just parsed. `info` now carries the
|
|
22
|
+
validated array alongside the derived `rawMessage` it already had. Purely
|
|
23
|
+
additive to a callback input: existing implementations ignore the new field.
|
|
24
|
+
|
|
25
|
+
## 0.11.1
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- **`createAuthClient`'s README section documented only 4 of `AuthClientConfig`'s
|
|
30
|
+
7 fields** — `baseUrl`, `credentials`, and `extractLoginError` had no mention
|
|
31
|
+
anywhere (only `permissionStore` was covered, in prose). Added a config table
|
|
32
|
+
matching the one already used for `createODataService`.
|
|
33
|
+
|
|
3
34
|
## 0.11.0
|
|
4
35
|
|
|
5
36
|
### Added
|
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ npm install @iyulab/enterprise
|
|
|
18
18
|
| `FormRow` | React | 2컬럼 그리드 폼 행(`full`로 1컬럼) |
|
|
19
19
|
| `ApiConfig` | class | baseUrl/OData·API prefix·dev 판별 중앙 설정 |
|
|
20
20
|
| `createODataService` | factory | OData v4 + custom REST CRUD 서비스(401·토스트·에러파싱) |
|
|
21
|
-
| `ApiError` | class | HTTP status 를 실은 API 호출 실패 에러 |
|
|
21
|
+
| `ApiError` | class | HTTP status + OData `error.details`(필드별 검증 상세)를 실은 API 호출 실패 에러 |
|
|
22
22
|
| `createAuthClient` | factory | 쿠키 세션 인증(fetchMe/login/logout) — 제네릭 user/자격증명 |
|
|
23
23
|
| `createPermissionStore` · `hasPermission` 외 | store | 권한 스냅샷 store + 판정 free 함수 |
|
|
24
24
|
| `CurrencyHelper` | class | 통화 포맷(`formatKRW` 등) |
|
|
@@ -121,10 +121,34 @@ await svc.apiPost<Order>('orders/7/attachments', form)
|
|
|
121
121
|
| `onUnauthorized(status)` | 401 시 호출 — 리다이렉트/재진입 가드는 앱이 처리 |
|
|
122
122
|
| `notify.success/error` | 토스트 훅 (생략 시 토스트 없음 — 순수) |
|
|
123
123
|
| `messages` | 사용자 대면 문구 (기본 영어, 지정 키만 대체) |
|
|
124
|
-
| `formatError(info)` | 에러 메시지 포매팅 오버라이드 (앱별 정책) |
|
|
124
|
+
| `formatError(info)` | 에러 메시지 포매팅 오버라이드 (앱별 정책) — `info` 는 `status`/`statusText`/`rawMessage`/`details`(검증된 `error.details`)/`body` 를 받는다 |
|
|
125
125
|
|
|
126
126
|
> 도메인 액션(상태 전이 등)·엔티티 목록·권한 코드는 라이브러리에 넣지 말고 앱 adapter 에 둔다.
|
|
127
127
|
|
|
128
|
+
#### 실패 응답 — `ApiError`
|
|
129
|
+
|
|
130
|
+
모든 메서드는 실패 시 `ApiError`(`Error` 상속)를 던진다. `status` 로 상태별 분기하고,
|
|
131
|
+
서버가 OData v4 오류 봉투에 필드별 검증 상세(`error.details`)를 실어 보내면 `details` 로
|
|
132
|
+
읽어 폼의 필드별 오류 표시에 바로 연결할 수 있다.
|
|
133
|
+
|
|
134
|
+
```typescript
|
|
135
|
+
try {
|
|
136
|
+
await svc.odataPost('Roles', draft)
|
|
137
|
+
} catch (e) {
|
|
138
|
+
if (e instanceof ApiError && e.details) {
|
|
139
|
+
// [{ code: 'ValidationError', message: 'The Name field is required.', target: 'Name' }, …]
|
|
140
|
+
for (const d of e.details) markFieldError(d.target, d.message)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
| 필드 | 설명 |
|
|
146
|
+
|------|------|
|
|
147
|
+
| `message` | 사용자 대면 메시지 (`formatError` → 서버 raw → status 폴백 순으로 결정) |
|
|
148
|
+
| `status` | HTTP status |
|
|
149
|
+
| `details` | `error.details` 항목 배열 — 규격상 `code`/`message` 는 필수, `target`(속성 이름)은 선택. 상세가 없거나 규격 형태가 아니면 `undefined` |
|
|
150
|
+
|
|
151
|
+
|
|
128
152
|
### 인증 + 권한 (`createAuthClient` · 권한 store)
|
|
129
153
|
|
|
130
154
|
쿠키 세션 인증 흐름과 권한 스냅샷을 승격. 사용자·자격증명 **형태는 앱이 제네릭으로 정의**하고, 권한 코드는 불투명 문자열로만 다룬다. `getPermissions` 를 주면 로그인/세션 조회 성공 시 권한 store 가 자동 갱신된다.
|
|
@@ -147,8 +171,16 @@ const user = await auth.fetchMe() // null → 미인증(로그인 화면)
|
|
|
147
171
|
if (hasPermission('orders.write')) { /* 저장 버튼 노출 */ }
|
|
148
172
|
```
|
|
149
173
|
|
|
174
|
+
주입 항목(위 예시가 쓴 것 외 나머지):
|
|
175
|
+
|
|
176
|
+
| config | 용도 |
|
|
177
|
+
|--------|------|
|
|
178
|
+
| `baseUrl` | 상대 URL 앞에 붙일 오리진 (기본 `''` = same-origin) |
|
|
179
|
+
| `credentials` | fetch `credentials` 모드 (기본 `'same-origin'` — 쿠키 세션) |
|
|
180
|
+
| `extractLoginError` | 로그인 실패(non-401) 응답 바디에서 서버 메시지 추출 오버라이드 (기본: `body.Message ?? body.message`) |
|
|
181
|
+
| `permissionStore` | 권한 자동 갱신 대상 store (기본 `defaultPermissionStore`) — 격리가 필요하면 `createPermissionStore()`로 별도 store를 만들어 주입 |
|
|
182
|
+
|
|
150
183
|
- `fetchMe()` 는 401/네트워크 오류 시 `null` — 이 신호가 로그인 게이트를 구동한다(라이브러리가 리다이렉트하지 않음).
|
|
151
|
-
- 격리가 필요하면 `createPermissionStore()` 로 별도 store 를 만들어 `permissionStore` 로 주입한다.
|
|
152
184
|
- 도메인 판정(`isPortalUser` 등)·권한 코드 상수는 라이브러리가 아니라 앱 adapter 에 둔다.
|
|
153
185
|
|
|
154
186
|
### 도메인 헬퍼
|
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
import { HttpResponse } from '@iyulab/http-client';
|
|
2
|
+
/**
|
|
3
|
+
* OData v4 오류 봉투의 `error.details` 항목 — 필드별 검증 실패 상세.
|
|
4
|
+
*
|
|
5
|
+
* 규격(OData JSON Format v4.0)상 각 항목은 `code`/`message` 를 **반드시** 갖고
|
|
6
|
+
* `target`(오류가 난 속성 이름)은 **선택**이다 — 필수로 선언하면 target 을 생략한
|
|
7
|
+
* 서버 응답에서 타입이 거짓말을 하게 된다.
|
|
8
|
+
*/
|
|
9
|
+
export interface ApiErrorDetail {
|
|
10
|
+
code: string;
|
|
11
|
+
message: string;
|
|
12
|
+
target?: string;
|
|
13
|
+
}
|
|
2
14
|
/**
|
|
3
15
|
* API 호출 실패 에러 — HTTP status 를 실어 호출부가 상태별 분기(예: 404 도메인 문구)를 할 수 있게 한다.
|
|
4
16
|
* `Error` 를 상속하므로 기존 `e instanceof Error`/`e.message` 소비처는 그대로 동작한다.
|
|
5
17
|
*/
|
|
6
18
|
export declare class ApiError extends Error {
|
|
7
19
|
readonly status: number;
|
|
8
|
-
|
|
20
|
+
/** OData v4 오류 봉투의 `error.details`(필드별 검증 상세) — 서버 응답에 없거나 파싱 실패면 undefined. */
|
|
21
|
+
readonly details?: ApiErrorDetail[];
|
|
22
|
+
constructor(message: string, status: number, details?: ApiErrorDetail[]);
|
|
9
23
|
}
|
|
10
24
|
/** 서비스가 토스트/에러에 쓰는 사용자 대면 문구. 기본값은 영어 — 앱이 로케일별로 오버라이드한다. */
|
|
11
25
|
export interface ODataServiceMessages {
|
|
@@ -49,6 +63,8 @@ export interface ODataServiceConfig {
|
|
|
49
63
|
status: number;
|
|
50
64
|
statusText: string;
|
|
51
65
|
rawMessage?: string;
|
|
66
|
+
/** OData v4 `error.details` — 검증을 마친 항목만 실린다(`ApiError.details` 와 같은 값). */
|
|
67
|
+
details?: ApiErrorDetail[];
|
|
52
68
|
body?: unknown;
|
|
53
69
|
}) => string | undefined;
|
|
54
70
|
}
|
package/dist/icons.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export {}
|
|
1
|
+
export {}
|
package/dist/index.js
CHANGED
|
@@ -613,14 +613,31 @@ var ApiConfig = class {
|
|
|
613
613
|
* const rows = await svc.odataGet<BankAccount>('BankAccounts', { $top: '20' })
|
|
614
614
|
*/
|
|
615
615
|
/**
|
|
616
|
+
* `error.details` 를 검증해 추출한다 — 규격이 요구하는 형태(`code`/`message` 둘 다 문자열)를
|
|
617
|
+
* 갖춘 항목만 남긴다. 이 파일이 `error.message` 에 이미 적용하는 규칙(`typeof === "string"` 을
|
|
618
|
+
* 확인한 뒤 사용)과 같은 이유다: 검증 없이 캐스팅하면 타입만 맞고 런타임에 호출부가 깨진다.
|
|
619
|
+
* 쓸 수 있는 항목이 하나도 없으면 undefined 로 정규화한다 — 빈 배열을 주면 호출부의
|
|
620
|
+
* `if (e.details)` 가 참이 되어 "상세가 있다"고 오해한다.
|
|
621
|
+
*/
|
|
622
|
+
function extractErrorDetails(raw) {
|
|
623
|
+
if (!Array.isArray(raw)) return void 0;
|
|
624
|
+
const details = raw.filter((d) => {
|
|
625
|
+
if (typeof d !== "object" || d === null) return false;
|
|
626
|
+
const rec = d;
|
|
627
|
+
return typeof rec.code === "string" && typeof rec.message === "string";
|
|
628
|
+
});
|
|
629
|
+
return details.length > 0 ? details : void 0;
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
616
632
|
* API 호출 실패 에러 — HTTP status 를 실어 호출부가 상태별 분기(예: 404 도메인 문구)를 할 수 있게 한다.
|
|
617
633
|
* `Error` 를 상속하므로 기존 `e instanceof Error`/`e.message` 소비처는 그대로 동작한다.
|
|
618
634
|
*/
|
|
619
635
|
var ApiError = class extends Error {
|
|
620
|
-
constructor(message, status) {
|
|
636
|
+
constructor(message, status, details) {
|
|
621
637
|
super(message);
|
|
622
638
|
this.name = "ApiError";
|
|
623
639
|
this.status = status;
|
|
640
|
+
this.details = details;
|
|
624
641
|
}
|
|
625
642
|
};
|
|
626
643
|
var DEFAULT_MESSAGES = {
|
|
@@ -674,27 +691,39 @@ function createODataService(config) {
|
|
|
674
691
|
const clean = path.startsWith("/") ? path.slice(1) : path;
|
|
675
692
|
return `${baseUrl}/${apiPrefix}/${clean}`;
|
|
676
693
|
};
|
|
677
|
-
/** OData 응답에서 사용자 친화적 에러 메시지 추출. */
|
|
678
|
-
async function
|
|
694
|
+
/** OData 응답에서 사용자 친화적 에러 메시지 + 구조화된 필드별 상세를 추출. */
|
|
695
|
+
async function extractErrorInfo(res) {
|
|
679
696
|
let body;
|
|
680
697
|
try {
|
|
681
698
|
body = await res.json();
|
|
682
699
|
} catch {
|
|
683
700
|
body = void 0;
|
|
684
701
|
}
|
|
685
|
-
const
|
|
702
|
+
const errorObj = body?.error;
|
|
703
|
+
const rawVal = errorObj?.message ?? body?.message ?? body?.Message;
|
|
686
704
|
const rawMessage = typeof rawVal === "string" ? rawVal : void 0;
|
|
705
|
+
const details = extractErrorDetails(errorObj?.details);
|
|
687
706
|
if (config.formatError) {
|
|
688
707
|
const m = config.formatError({
|
|
689
708
|
status: res.status,
|
|
690
709
|
statusText: res.statusText,
|
|
691
710
|
rawMessage,
|
|
711
|
+
details,
|
|
692
712
|
body
|
|
693
713
|
});
|
|
694
|
-
if (m) return
|
|
714
|
+
if (m) return {
|
|
715
|
+
message: m,
|
|
716
|
+
details
|
|
717
|
+
};
|
|
695
718
|
}
|
|
696
|
-
if (rawMessage && rawMessage.length <= 200) return
|
|
697
|
-
|
|
719
|
+
if (rawMessage && rawMessage.length <= 200) return {
|
|
720
|
+
message: rawMessage,
|
|
721
|
+
details
|
|
722
|
+
};
|
|
723
|
+
return {
|
|
724
|
+
message: messages.http[res.status] ?? `${messages.requestFailed} (${res.status})`,
|
|
725
|
+
details
|
|
726
|
+
};
|
|
698
727
|
}
|
|
699
728
|
/** 에러 확인 후 throw. 401 은 onUnauthorized 통지 후 세션 만료 에러로 단락. */
|
|
700
729
|
async function throwIfError(res) {
|
|
@@ -703,7 +732,8 @@ function createODataService(config) {
|
|
|
703
732
|
config.onUnauthorized?.(401);
|
|
704
733
|
throw new ApiError(messages.sessionExpired, 401);
|
|
705
734
|
}
|
|
706
|
-
|
|
735
|
+
const { message, details } = await extractErrorInfo(res);
|
|
736
|
+
throw new ApiError(message, res.status, details);
|
|
707
737
|
}
|
|
708
738
|
async function odataGet(entity, params) {
|
|
709
739
|
const u = new URL(odataUrl(entity));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iyulab/enterprise",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Enterprise utilities and components for iyulab framework",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"enterprise",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"./styles/preset.css": "./dist/styles/preset.css"
|
|
46
46
|
},
|
|
47
47
|
"scripts": {
|
|
48
|
-
"preversion": "node ../../scripts/preversion-check.mjs",
|
|
48
|
+
"preversion": "node -e \"if(require('fs').existsSync('../../scripts/preversion-check.mjs'))require('child_process').execFileSync('node',['../../scripts/preversion-check.mjs'],{stdio:'inherit'})\"",
|
|
49
49
|
"build": "npm run typecheck && vite build && node scripts/copy-styles.mjs",
|
|
50
50
|
"test": "vitest run",
|
|
51
51
|
"typecheck": "tsc --noEmit"
|