@iyulab/enterprise 0.11.1 → 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 CHANGED
@@ -1,5 +1,27 @@
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
+
3
25
  ## 0.11.1
4
26
 
5
27
  ### Fixed
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 가 자동 갱신된다.
@@ -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
- constructor(message: string, status: number);
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/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 extractErrorMsg(res) {
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 rawVal = (body?.error)?.message ?? body?.message ?? body?.Message;
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 m;
714
+ if (m) return {
715
+ message: m,
716
+ details
717
+ };
695
718
  }
696
- if (rawMessage && rawMessage.length <= 200) return rawMessage;
697
- return messages.http[res.status] ?? `${messages.requestFailed} (${res.status})`;
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
- throw new ApiError(await extractErrorMsg(res), res.status);
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.11.1",
3
+ "version": "0.12.0",
4
4
  "description": "Enterprise utilities and components for iyulab framework",
5
5
  "keywords": [
6
6
  "enterprise",