From e69d9d6fa01ca159d118ad24ce6d715e7caa5f8a Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Wed, 8 Apr 2026 12:45:32 +0900 Subject: [PATCH 01/24] =?UTF-8?q?ci:=20beta=20release-please=EC=97=90=20ve?= =?UTF-8?q?rsioning-strategy:=20prerelease=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit prerelease, prerelease-type만으로는 버전에 beta suffix가 붙지 않음. versioning-strategy를 prerelease로 명시해야 6.0.0-beta.0 형식이 생성됨. Co-Authored-By: Claude Opus 4.6 (1M context) --- release-please-config-beta.json | 1 + 1 file changed, 1 insertion(+) diff --git a/release-please-config-beta.json b/release-please-config-beta.json index adca8490..728f064f 100644 --- a/release-please-config-beta.json +++ b/release-please-config-beta.json @@ -5,6 +5,7 @@ "release-type": "node", "prerelease": true, "prerelease-type": "beta", + "versioning-strategy": "prerelease", "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, "changelog-sections": [ From 3c640897588b5f96841f2a8ff322a55e66500162 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Wed, 8 Apr 2026 14:44:30 +0900 Subject: [PATCH 02/24] =?UTF-8?q?ci:=20versioning-strategy=20=E2=86=92=20v?= =?UTF-8?q?ersioning=20=EC=86=8D=EC=84=B1=EB=AA=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit release-please config schema의 올바른 속성명은 "versioning"이다. "versioning-strategy"는 존재하지 않는 속성으로 무시되어 prerelease 버전(6.0.0-beta.0)이 생성되지 않았다. Co-Authored-By: Claude Opus 4.6 (1M context) --- release-please-config-beta.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release-please-config-beta.json b/release-please-config-beta.json index 728f064f..f3e065f6 100644 --- a/release-please-config-beta.json +++ b/release-please-config-beta.json @@ -5,7 +5,7 @@ "release-type": "node", "prerelease": true, "prerelease-type": "beta", - "versioning-strategy": "prerelease", + "versioning": "prerelease", "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, "changelog-sections": [ From 7502a08d0ac9cbf6cffbf4b1720a49a3d49bbc3e Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Wed, 8 Apr 2026 14:54:03 +0900 Subject: [PATCH 03/24] =?UTF-8?q?ci:=20prerelease-type=EC=9D=84=20beta.0?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD=ED=95=98=EC=97=AC=20?= =?UTF-8?q?=EC=8B=9C=EB=A6=AC=EC=96=BC=20=EB=84=98=EB=B2=84=EB=A7=81=20?= =?UTF-8?q?=ED=99=9C=EC=84=B1=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "beta"로 설정하면 첫 버전이 6.0.0-beta(카운터 없음)로 생성된다. "beta.0"으로 설정하면 6.0.0-beta.0부터 시작하고, 이후 bump 시 beta.1, beta.2로 시리얼하게 증가한다. Co-Authored-By: Claude Opus 4.6 (1M context) --- release-please-config-beta.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release-please-config-beta.json b/release-please-config-beta.json index f3e065f6..d3ff3dbb 100644 --- a/release-please-config-beta.json +++ b/release-please-config-beta.json @@ -4,7 +4,7 @@ ".": { "release-type": "node", "prerelease": true, - "prerelease-type": "beta", + "prerelease-type": "beta.0", "versioning": "prerelease", "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, From 71e5989bddbfe9b4fa329915c1dbfbd1032af7f4 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Tue, 14 Apr 2026 13:46:58 +0900 Subject: [PATCH 04/24] =?UTF-8?q?chore:=20.claude=20=EC=97=90=EC=9D=B4?= =?UTF-8?q?=EC=A0=84=ED=8A=B8=20=EB=B0=8F=20=EC=8A=A4=ED=82=AC=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EB=B3=B5=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit upstream/beta 동기화 시 보존 대상 파일 복원: - agents: barrel-checker, effect-reviewer, tidy-first - skills: create-model, gen-e2e-test Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/agents/barrel-checker.md | 58 ++++++++++++ .claude/agents/effect-reviewer.md | 51 +++++++++++ .claude/agents/tidy-first.md | 6 +- .claude/skills/create-model/SKILL.md | 132 +++++++++++++++++++++++++++ .claude/skills/gen-e2e-test/SKILL.md | 118 ++++++++++++++++++++++++ 5 files changed, 363 insertions(+), 2 deletions(-) create mode 100644 .claude/agents/barrel-checker.md create mode 100644 .claude/agents/effect-reviewer.md create mode 100644 .claude/skills/create-model/SKILL.md create mode 100644 .claude/skills/gen-e2e-test/SKILL.md diff --git a/.claude/agents/barrel-checker.md b/.claude/agents/barrel-checker.md new file mode 100644 index 00000000..0e97aeb4 --- /dev/null +++ b/.claude/agents/barrel-checker.md @@ -0,0 +1,58 @@ +--- +name: barrel-checker +description: src/ 하위 새 파일이 barrel export(index.ts)에 포함되었는지 검증하는 에이전트. +tools: Read, Grep, Glob, Bash +model: inherit +--- + +You are a barrel export consistency checker for the solapi-nodejs SDK. +v6.0.0에서 전체 타입 Export 방식을 채택했으며, barrel 패턴(index.ts re-export)을 유지해야 합니다. + +## Export Structure + +``` +src/index.ts ← 최상위 entry point +├── src/errors/defaultError.ts ← 직접 export +├── src/models/index.ts ← barrel (base, requests, responses 통합) +│ ├── src/models/base/... ← 개별 파일을 models/index.ts에서 직접 re-export +│ ├── src/models/requests/index.ts ← 서브 barrel +│ └── src/models/responses/index.ts ← 서브 barrel +├── src/types/index.ts ← barrel (commonTypes.ts 등을 직접 re-export) +├── src/lib/... ← barrel 대상 아님 (내부 유틸리티) +└── src/services/... ← barrel 대상 아님 (SolapiMessageService에서 위임) +``` + +**검사 제외 대상**: `src/lib/`, `src/services/`는 barrel export 체인에 포함되지 않음. + +## Check Process + +1. `src/models/`, `src/types/`, `src/errors/` 하위의 모든 `.ts` 파일 수집 (`index.ts` 제외) +2. 모든 파일을 검사 대상으로 포함 (export가 없는 파일도 검사 — export 누락 자체가 문제일 수 있음) +3. 해당 파일이 적절한 barrel `index.ts`에서 re-export되는지 확인: + - `src/models/base/` 파일 → `src/models/index.ts`에서 직접 re-export (중간 index.ts 불필요) + - `src/models/requests/` 파일 → `src/models/requests/index.ts` → `src/models/index.ts` + - `src/models/responses/` 파일 → `src/models/responses/index.ts` → `src/models/index.ts` + - `src/models/base/kakao/bms/` 파일 → `bms/index.ts` → `src/models/index.ts` + - `src/types/` 파일 → `src/types/index.ts`에서 직접 re-export + - `src/errors/` 파일 → `src/index.ts`에서 직접 re-export (errors/index.ts 없음) +4. re-export 체인이 `src/index.ts`까지 연결되는지 확인 + +**중요**: 실제 barrel 구조를 먼저 읽어서 확인하세요. 중간 index.ts가 없는 디렉토리의 파일은 상위 barrel에서 직접 re-export됩니다. + +## Export Pattern + +```typescript +// Named re-export (권장) +export { + type KakaoButton, + kakaoButtonSchema, +} from './base/kakao/kakaoButton'; + +// Wildcard re-export (서브 barrel용) +export * from './requests/index'; +``` + +## Report + +누락된 export를 `파일 — barrel 위치`로 리포트하고, 추가할 export 코드를 제안. +export가 없는 파일은 별도로 경고 (의도적 private 파일인지 확인 필요). diff --git a/.claude/agents/effect-reviewer.md b/.claude/agents/effect-reviewer.md new file mode 100644 index 00000000..c673b38d --- /dev/null +++ b/.claude/agents/effect-reviewer.md @@ -0,0 +1,51 @@ +--- +name: effect-reviewer +description: Effect 공식문서 원칙에 기반한 코드 리뷰 에이전트. 타입 안전 에러 처리, 의존성 주입, Schema 패턴 준수를 검증. +tools: Read, Grep, Glob, Bash +model: inherit +--- + +You are an Effect library pattern reviewer for the solapi-nodejs SDK. +All reviews MUST align with Effect official documentation (https://effect.website/docs/). +프로젝트 기본 규칙은 CLAUDE.md 참조. 이 문서는 Effect 특화 리뷰 항목만 기술합니다. + +## Review Checklist + +### A. 에러 처리 + +- Effect 경계를 벗어나는 `throw new Error(...)` → `Data.TaggedError` 사용 필수 + - `Effect.tryPromise` 콜백 내부 throw는 `catch` 옵션으로 타입 에러 매핑 시에만 허용 (예: `defaultFetcher.ts`의 `catch` → `DefaultError`). `catch` 없으면 `UnknownException`이 되어 타입 안전성 상실 +- Effect 코드 주변의 `try { ... } catch` → `Effect.catchTag`/`catchAll`/`catchTags`/`either` 사용 필수 + - 주의: 비-Effect 코드(`fileToBase64.ts` 등)의 try-catch는 허용됨. Effect 파이프라인 내부만 검사 +- 에러를 조용히 무시하는 패턴 → 반드시 명시적 처리 또는 타입 시스템 통한 전파 +- `Effect.gen` 내부에서 throw 가능한 함수 호출 시: + - `JSON.parse`, `Schema.decodeUnknownSync` 등 → `Effect.try`로 래핑 필수 + - `Schema.decodeUnknownEither`는 throw하지 않으므로 래핑 불필요 +- `runSafePromise`에서 `Data.TaggedError`를 이중 래핑하지 않고 원본 그대로 전달 + +### B. 타입 안전성 + +- `any` 타입 → `unknown` + type guard 또는 Effect Schema +- `Error` 채널에 generic `Error` 사용 금지 → `Data.TaggedError` 기반 discriminated union + +### C. Effect.gen 사용 + +- 단일 `yield*` Effect.gen → `flatMap`/`map`/`andThen`으로 간소화 +- `function*` + `yield*` 사용 확인 (`yield` 아님) + - 참고: AGENTS.md에 `function* (_)` adapter 패턴이 문서화되어 있으나, 실제 코드베이스는 모두 adapter 없는 `function* ()` 사용. 새 코드는 adapter 없는 패턴 권장 + +### D. 의존성 주입 (테스트 코드 대상) + +- `yield* ServiceTag` / `Layer.provide` 패턴은 `test/` 코드에서만 사용 +- `src/services/`의 프로덕션 서비스는 class 기반(`DefaultService` 상속) — DI 규칙 적용 대상 아님 +- 테스트에서 Requirements 타입이 모든 의존성을 union으로 추적하는지 확인 + +## Review Process + +1. 대상 파일 목록 수집 (git diff 또는 지정 경로) +2. 각 파일에서 위 체크리스트 항목별 위반 검색 +3. 위반 사항을 `파일:라인` 형식으로 보고, 공식문서 기반 수정 제안 포함 + +## Report Format + +위반/경고/통과를 `파일:라인` 형식으로 분류하여 보고. 마지막에 `위반: N건 / 경고: N건 / 통과: N건` 요약 포함. diff --git a/.claude/agents/tidy-first.md b/.claude/agents/tidy-first.md index d5c8099f..1700236d 100644 --- a/.claude/agents/tidy-first.md +++ b/.claude/agents/tidy-first.md @@ -47,13 +47,14 @@ ALWAYS ask this question before adding features: 2. **Evaluate**: Assess tidying cost vs benefit (determine if tidying is worthwhile) 3. **Verify Tests**: Ensure existing tests pass 4. **Apply**: Apply only one tidying type at a time -5. **Validate**: Re-run tests after changes (`pnpm test`) +5. **Validate**: Run full validation (`pnpm lint && pnpm test && pnpm build`) 6. **Suggest Commit**: Propose commit message in Conventional Commits format ## Project Rules Compliance -Follow this project's code style: +Follow CLAUDE.md Core Principles and this project's code style: +- **Core Principles**: Zero Tolerance for Errors, Clarity over Cleverness, Conciseness, Reduce Comments, Read Before Writing - **Effect Library**: Maintain `Effect.gen`, `pipe`, `Data.TaggedError` style - **Type Safety**: Never use `any` type - use `unknown` with type guards or Effect Schema - **Linting**: Follow Biome lint rules (`pnpm lint`) @@ -66,6 +67,7 @@ Follow this project's code style: - **Tests required**: Verify all tests pass after every change - **Separate commits**: Keep structural and behavioral changes in separate commits - **Incremental improvement**: Apply only one tidying type at a time +- **Test awareness**: Tidying 후 테스트가 성공/실패 경로를 모두 커버하는지 확인 ## Commit Message Format diff --git a/.claude/skills/create-model/SKILL.md b/.claude/skills/create-model/SKILL.md new file mode 100644 index 00000000..c64ee134 --- /dev/null +++ b/.claude/skills/create-model/SKILL.md @@ -0,0 +1,132 @@ +--- +name: create-model +description: Effect Schema 기반 모델/요청 타입을 프로젝트 패턴에 맞게 스캐폴딩. barrel export, 테스트 파일 포함. +disable-model-invocation: true +--- + +# create-model + +Effect Schema(https://effect.website/docs/schema/introduction/) 원칙에 따라 모델을 생성합니다. +프로젝트 검증 규칙은 CLAUDE.md "Mandatory Validation" 참조. + +## Usage + +``` +/create-model [--type base|request|response] [--domain ] +``` + +### 타입별 유효 도메인 + +| type | 유효 도메인 | +|------|-----------| +| base | messages, kakao, kakao/bms*, naver, rcs | + +\* **kakao/bms 주의**: BMS 모델은 스키마 파일 + barrel export 외에 `src/models/base/kakao/kakaoOption.ts`의 `bmsChatBubbleTypeSchema`, `baseBmsSchema`, `BMS_REQUIRED_FIELDS`에도 통합이 필요합니다. +| request | common, iam, kakao, messages, voice | +| response | iam, kakao (또는 responses/ 루트에 직접 배치) | + +``` +# 예시 +/create-model VoiceOption --type request --domain voice +``` + +## Step 1: 기존 패턴 확인 + +생성 전 반드시 동일 도메인의 기존 모델을 Read 도구로 읽어서 일관성을 유지합니다. + +## Step 2: 모델 파일 생성 + +### Schema 정의 패턴 + +```typescript +import {Schema} from 'effect'; + +export const Schema = Schema.Struct({ + fieldName: Schema.String, + optionalField: Schema.optional(Schema.String), + // optional: 키 자체가 없을 수 있음 + NullOr: 값이 null일 수 있음 + nullableField: Schema.optional(Schema.NullOr(Schema.String)), + status: Schema.Literal('ACTIVE', 'INACTIVE'), +}); + +export type = Schema.Schema.TypeSchema>; +``` + +### 네이밍 규칙 + +| 대상 | 패턴 | 예시 | +|------|------|------| +| Schema 변수 | camelCase + `Schema` 접미사 | `kakaoButtonSchema` | +| Type | PascalCase | `KakaoButton` | +| 파일명 | camelCase | `kakaoButton.ts` | + +### Discriminated Union 패턴 + +```typescript +export const buttonSchema = Schema.Union( + webButtonSchema, + appButtonSchema, +); +``` + +### Transform 패턴 + +```typescript +// 주의: normalize 목적의 transform은 round-trip을 보장하지 않음 +export const phoneSchema = Schema.String.pipe( + Schema.transform(Schema.String, { + decode: removeHyphens, + encode: s => s, + }), + Schema.filter(s => /^[0-9]+$/.test(s), { + message: () => '숫자만 포함해야 합니다.', + }), +); +``` + +## Step 3: Barrel Export 업데이트 + +barrel-checker 에이전트 규칙에 따라 가장 가까운 `index.ts`에 re-export 추가. +체인이 `src/index.ts`까지 연결되는지 확인. + +```typescript +export { + type , + Schema, +} from './/'; +``` + +## Step 4: 테스트 파일 생성 + +`test/models/` 하위에 대응하는 테스트 파일: + +```typescript +import {Schema} from 'effect'; +import {describe, expect, it} from 'vitest'; +import {Schema} from '@models//'; + +describe('Schema', () => { + it('should decode valid input', () => { + const result = Schema.decodeUnknownEither(Schema)({ /* valid */ }); + expect(result._tag).toBe('Right'); + }); + + it('should reject invalid input', () => { + const result = Schema.decodeUnknownEither(Schema)({ /* invalid */ }); + expect(result._tag).toBe('Left'); + }); + + it.each([ + ['null field', { field: null }], + ['empty string', { field: '' }], + ['missing required', {}], + ])('should handle edge case: %s', (_label, input) => { + const result = Schema.decodeUnknownEither(Schema)(input); + // assert based on schema definition + }); +}); +``` + +## Step 5: 검증 + +CLAUDE.md "Mandatory Validation" 순서대로 `pnpm lint` → `pnpm test` → `pnpm build` 실행. diff --git a/.claude/skills/gen-e2e-test/SKILL.md b/.claude/skills/gen-e2e-test/SKILL.md new file mode 100644 index 00000000..6a8f2d84 --- /dev/null +++ b/.claude/skills/gen-e2e-test/SKILL.md @@ -0,0 +1,118 @@ +--- +name: gen-e2e-test +description: Effect 기반 E2E 테스트를 프로젝트 패턴(it.effect, Layer, Effect.either)에 맞게 생성. Effect 공식문서 원칙 준수. +disable-model-invocation: true +--- + +# gen-e2e-test + +`@effect/vitest`의 `it.effect()` 패턴으로 E2E 테스트를 생성합니다. +Effect 공식문서: https://effect.website/docs/ + +## Usage + +``` +/gen-e2e-test [--methods method1,method2] +``` + +## Step 1: 대상 서비스 분석 + +Read 도구로 서비스 구현과 기존 E2E 테스트를 읽습니다. + +**중요**: 일부 서비스(cashService, iamService 등)는 plain vitest + async/await 패턴을 사용합니다. 기존 테스트가 있다면 해당 패턴을 따르고, 새로 작성하는 경우 아래 Effect 패턴(권장)을 사용합니다. + +## Step 2: Layer 확인 + +`test/lib/test-layers.ts`에서 대상 서비스의 Layer 정의 확인. + +### Layer가 없는 경우 — `test/lib/test-layers.ts`에 추가 + +`createServiceLayer`는 해당 파일 내부의 비공개 헬퍼입니다. 기존 정의 옆에 추가: + +```typescript +export const Tag = Context.GenericTag<>(''); + +export const Live = createServiceLayer( + Tag, + , +); +``` + +## Step 3: E2E 테스트 생성 + +### Happy Path + +```typescript +import {describe, expect, it} from '@effect/vitest'; +import {Effect} from 'effect'; + +describe(' E2E', () => { + it.effect('should <동작 설명>', () => + Effect.gen(function* () { + const service = yield* Tag; + + const result = yield* Effect.tryPromise(() => + service.(), + ); + + expect(result).toBeDefined(); + }).pipe(Effect.provide(Live)), + ); +}); +``` + +### Error Path — Effect.either + +```typescript +it.effect('should handle <에러 상황> gracefully', () => + Effect.gen(function* () { + const service = yield* Tag; + + const result = yield* Effect.either( + Effect.tryPromise(() => + service.(/* invalid args */), + ), + ); + + expect(result._tag).toBe('Left'); + if (result._tag === 'Left') { + // Effect.tryPromise는 UnknownException으로 래핑 — .error로 원본 에러 접근 + expect(String(result.left.error)).toContain('예상되는 에러 메시지'); + } + }).pipe(Effect.provide(Live)), +); +``` + +### 병렬 호출 + +```typescript +// Effect.all은 기본 순차 실행. 병렬 실행 시 concurrency 옵션 필수 +const [r1, r2] = yield* Effect.all([ + Effect.tryPromise(() => service.method1()), + Effect.tryPromise(() => service.method2()), +], {concurrency: 'unbounded'}); +``` + +### 환경변수 + +```typescript +// Effect.gen 내부에서 yield*로 사용 +const sender = yield* Config.string('SOLAPI_SENDER').pipe( + Config.withDefault('01000000000'), +); +``` + +## Step 4: 검증 + +CLAUDE.md "Mandatory Validation" 순서대로 `pnpm lint` → `pnpm test` → `pnpm build` 실행. + +## Checklist + +기존 plain vitest 테스트를 확장하는 경우, 해당 파일의 기존 패턴을 따릅니다. +새로 작성하는 Effect 패턴 테스트의 경우: + +- [ ] `@effect/vitest`에서 import (`vitest` 아님) +- [ ] `it.effect()` + `Effect.gen(function* () { ... })` +- [ ] `.pipe(Effect.provide(Layer))` 필수 +- [ ] Happy path + Error path 모두 테스트 +- [ ] `Effect.tryPromise` 에러는 `UnknownException` — `.error`로 원본 접근 From 0421bfc8d480b039cc0b2fa4175e488cd22823e0 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Tue, 14 Apr 2026 13:51:23 +0900 Subject: [PATCH 05/24] =?UTF-8?q?chore:=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20.cursor/rules=20=EB=94=94?= =?UTF-8?q?=EB=A0=89=ED=86=A0=EB=A6=AC=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../rules/effect-functional-programming.mdc | 304 ---------------- .cursor/rules/error-handling-production.mdc | 324 ------------------ .cursor/rules/tdd-rules.mdc | 99 ------ 3 files changed, 727 deletions(-) delete mode 100644 .cursor/rules/effect-functional-programming.mdc delete mode 100644 .cursor/rules/error-handling-production.mdc delete mode 100644 .cursor/rules/tdd-rules.mdc diff --git a/.cursor/rules/effect-functional-programming.mdc b/.cursor/rules/effect-functional-programming.mdc deleted file mode 100644 index e06e776f..00000000 --- a/.cursor/rules/effect-functional-programming.mdc +++ /dev/null @@ -1,304 +0,0 @@ ---- -description: -globs: -alwaysApply: true ---- -# EFFECT Library Utilization Guide - -This is a project rule for maximizing the use of the Effect library to achieve error control, functional programming, and performance optimization. - -## Core Principles - -- Leverage Effect's type safety to catch runtime errors at compile time -- Explicitly manage side effects with pure functional style -- Use Effect's pipeline for readable data transformations -- Specify error handling at the type level to clearly express exceptional situations - -## Error Handling Patterns - -### Utilizing Effect Data Types - -All custom errors should be defined by extending `Data.TaggedError`: - -```typescript -export class ValidationError extends Data.TaggedError('ValidationError')<{ - readonly field: string; - readonly reason: string; - readonly context?: Record; -}> { - toString(): string { - return process.env.NODE_ENV === 'production' - ? `ValidationError: ${this.field} validation failed` - : `ValidationError: ${this.field} - ${this.reason}`; - } -} -``` - -### Error Formatting Strategy - -To avoid long stack traces from minified code in production environments: - -1. **Concise Error Messages**: Display only essential information in production -2. **Limited Context Information**: Include detailed debugging information only in development environments -3. **Stack Trace Control**: Remove unnecessary stacks with Effect's error handling - -### Error Propagation Patterns - -```typescript -// Correct pattern: Error propagation through Effect chain -const processData = (input: unknown) => - pipe( - Effect.succeed(input), - Effect.flatMap(validateInput), - Effect.flatMap(transformData), - Effect.flatMap(saveToDatabase), - Effect.catchAll(handleError) - ); - -// Pattern to avoid: Wrapping Effect with try-catch -const badPattern = async (input: unknown) => { - try { - return await Effect.runPromise(processData(input)); - } catch (error) { - // Loses Effect's type safety - throw error; - } -}; -``` - -## Functional Programming Patterns - -### Utilizing Effect.gen - -Implement complex business logic with `Effect.gen`: - -```typescript -const businessLogic = Effect.gen(function* (_) { - const config = yield* _(loadConfig); - const data = yield* _(fetchData(config)); - const processed = yield* _(processData(data)); - const result = yield* _(saveResult(processed)); - return result; -}); -``` - -### Pipeline Operations - -Express data transformations as pipelines: - -```typescript -const transformUserData = (rawData: unknown) => - pipe( - rawData, - Schema.decodeUnknown(UserSchema), - Effect.map(user => ({...user, id: generateId()})), - Effect.flatMap(validateUser), - Effect.map(normalizeData) - ); -``` - -### Schema Validation Utilization - -Maximize the use of Effect Schema for runtime validation: - -```typescript -// Reference: [src/models/base/kakao/kakaoOption.ts](mdc:src/models/base/kakao/kakaoOption.ts) -const KakaoVariablesSchema = Schema.Record({ - key: Schema.String, - value: Schema.String -}).pipe( - Schema.transform( - Schema.Record({key: Schema.String, value: Schema.String}), - { - decode: variables => transformVariables(variables), - encode: variables => variables - } - ) -); -``` - -## Performance Optimization Patterns - -### Batch Processing - -Use Effect.all when processing multiple tasks in batches: - -```typescript -// Parallel processing instead of sequential processing -const processMultipleItems = (items: readonly Item[]) => - Effect.all( - items.map(item => processItem(item)), - { concurrency: 10 } // Limit concurrent execution - ); -``` - -### Resource Management - -Safe resource management with Effect.acquireRelease: - -```typescript -const withDatabase = ( - operation: (db: Database) => Effect.Effect -): Effect.Effect => - Effect.acquireRelease( - connectToDatabase, - (db) => Effect.promise(() => db.close()) - ).pipe( - Effect.flatMap(operation) - ); -``` - -### Caching Strategy - -Memoization using Effect.cached: - -```typescript -const expensiveComputation = Effect.cached( - computeHeavyOperation, - { timeToLive: "1 hour" } -); -``` - -## Project-Specific Application Rules - -### API Client Pattern - -Reference: [src/lib/defaultFetcher.ts](mdc:src/lib/defaultFetcher.ts) - -All API calls should be implemented based on Effect: - -```typescript -const apiCall = (request: ApiRequest): Effect.Effect => - pipe( - Effect.tryPromise({ - try: () => fetch(request.url, buildRequestOptions(request)), - catch: (error) => new NetworkError({ cause: error }) - }), - Effect.flatMap(handleHttpResponse), - Effect.retry(retryPolicy) - ); -``` - -### Service Layer Pattern - -Reference: [src/services/messages/messageService.ts](mdc:src/services/messages/messageService.ts) - -All service methods should be composed with Effect chains: - -```typescript -export class MessageService { - send(messages: MessageRequest[]): Promise { - const effect = Effect.gen(function* (_) { - const validated = yield* _(validateMessages(messages)); - const transformed = yield* _(transformMessages(validated)); - const response = yield* _(sendToApi(transformed)); - return yield* _(processResponse(response)); - }); - - return runSafePromise(effect); - } -} -``` - -### Error Transformation Layer - -For compatibility with existing Promise-based code: - -```typescript -export const runSafePromise = ( - effect: Effect.Effect -): Promise => - Effect.runPromiseExit(effect).then( - Exit.match({ - onFailure: (cause) => { - const formatted = formatErrorForProduction(cause); - return Promise.reject(new Error(formatted)); - }, - onSuccess: (value) => Promise.resolve(value) - }) - ); -``` - -## Testing Strategy - -### Effect-Based Testing - -Reference: [test/models/base/kakao/kakaoOption.test.ts](mdc:test/models/base/kakao/kakaoOption.test.ts) - -Execute Effect-based tests with `Effect.either`: - -```typescript -it('should validate input correctly', async () => { - const result = await Effect.runPromise( - Effect.either(validateInput(invalidData)) - ); - - expect(result._tag).toBe('Left'); - if (result._tag === 'Left') { - expect(result.left).toBeInstanceOf(ValidationError); - } -}); -``` - -### Mocking and Dependency Injection - -Test doubles using Effect Context: - -```typescript -const TestDatabase = Context.GenericTag('TestDatabase'); -const MockDatabaseLive = Layer.succeed(TestDatabase, mockDatabase); - -const testEffect = myBusinessLogic.pipe( - Effect.provide(MockDatabaseLive) -); -``` - -## Migration Strategy - -### Gradual Introduction - -1. **Start with Error Types**: Convert existing Error classes to Effect Data types -2. **Convert Utility Functions**: Refactor pure functions to be Effect-based -3. **Convert API Layer**: Convert external communication code to be Effect-based -4. **Convert Business Logic**: Convert core logic to Effect.gen - -### Maintaining Compatibility - -For compatibility with existing Promise-based APIs: - -```typescript -// Maintain existing API while using Effect internally -public async legacyMethod(input: string): Promise { - const effect = modernEffectBasedLogic(input); - return runSafePromise(effect); -} -``` - -## Build and Deployment Considerations - -### Environment-Specific Configuration - -Reference: [tsup.config.ts](mdc:tsup.config.ts) - -Optimize error formatting in production builds: - -```typescript -define: { - 'process.env.NODE_ENV': isProd ? '"production"' : '"development"', - 'process.env.EFFECT_DEBUG': isDev ? 'true' : 'false' -} -``` - -### Bundle Size Optimization - -Use ES module imports for Effect library tree-shaking: - -```typescript -// Good pattern -import { Effect, pipe } from 'effect'; - -// Pattern to avoid -import * as Effect from 'effect'; -``` - -Follow this guide to maximize the powerful features of the Effect library and write type-safe, performance-optimized functional code. diff --git a/.cursor/rules/error-handling-production.mdc b/.cursor/rules/error-handling-production.mdc deleted file mode 100644 index f3651f45..00000000 --- a/.cursor/rules/error-handling-production.mdc +++ /dev/null @@ -1,324 +0,0 @@ ---- -description: Reference this document when you need to add errors in specific services or handle failure processing. -alwaysApply: false ---- - -# Production Error Handling and Stack Trace Optimization - -This is a rule for solving the problem of long error stack traces caused by minified code in production builds. - -## Problem Definition - -Reference: [debug/index.js](mdc:debug/index.js) - -Due to tsup's minify option in production environments: - -- All code is compressed into a single line -- Long minified code appears in stack traces when errors occur -- Debugging becomes difficult and logs become messy - -## Solution Strategy - -### 1. Error Classes Using Effect Data Types - -All error classes should provide different message formats for different environments: - -```typescript -export class CustomError extends Data.TaggedError('CustomError')<{ - readonly code: string; - readonly message: string; - readonly context?: Record; -}> { - toString(): string { - if (process.env.NODE_ENV === 'production') { - // Production: Only concise messages - return `${this.code}: ${this.message}`; - } - - // Development: Include detailed information - return `${this.code}: ${this.message}${ - this.context ? `\nContext: ${JSON.stringify(this.context, null, 2)}` : '' - }`; - } -} -``` - -### 2. Utilizing Error.captureStackTrace - -Remove constructor stack from custom errors: - -```typescript -abstract class BaseError extends Error { - constructor(message: string, name: string) { - super(message); - this.name = name; - - // Remove this class's constructor from the stack - if (Error.captureStackTrace) { - Error.captureStackTrace(this, this.constructor); - } - - // Simplify stack trace in production - if (process.env.NODE_ENV === 'production') { - this.cleanStackTrace(); - } - } - - private cleanStackTrace() { - if (this.stack) { - // Keep only the error message - this.stack = `${this.name}: ${this.message}`; - } - } -} -``` - -### 3. Effect-Based Error Formatter - -Error formatting utilizing Effect's Cause system: - -```typescript -export const formatErrorForProduction = ( - cause: Cause.Cause, -): string => { - if (process.env.NODE_ENV === 'production') { - // Production: Only top-level error messages - const failure = Cause.failureOption(cause); - if (failure._tag === 'Some') { - const error = failure.value; - if (error instanceof Error) { - return `${error.name}: ${error.message}`; - } - return String(error); - } - return 'Unknown error occurred'; - } - - // Development: Full cause tree - return Cause.pretty(cause); -}; -``` - -### 4. Safe Effect Execution Utility - -Apply error formatting when converting Effect to Promise: - -```typescript -export const runSafePromise = (effect: Effect.Effect): Promise => - Effect.runPromiseExit(effect).then( - Exit.match({ - onFailure: cause => { - const formattedError = formatErrorForProduction(cause); - const error = new Error(formattedError); - - // Remove stack trace in production - if (process.env.NODE_ENV === 'production') { - error.stack = undefined; - } - - return Promise.reject(error); - }, - onSuccess: value => Promise.resolve(value), - }), - ); -``` - -## Build Configuration Optimization - -### tsup Configuration Improvement - -Reference: [tsup.config.ts](mdc:tsup.config.ts) - -Conditional builds through environment variables: - -```typescript -export default defineConfig(({watch}) => { - const isProd = !watch; - const enableDebug = process.env.DEBUG === 'true'; - - return { - // ... existing configuration ... - - // Disable minify in debug mode - minify: isProd && !enableDebug, - - // Generate source maps in debug mode - sourcemap: !isProd || enableDebug, - - // Define environment variables - define: { - 'process.env.NODE_ENV': isProd ? '"production"' : '"development"', - 'process.env.EFFECT_DEBUG': enableDebug ? '"true"' : '"false"', - }, - }; -}); -``` - -### Adding package.json Scripts - -```json -{ - "scripts": { - "build": "yarn lint && tsup", - "build:debug": "DEBUG=true yarn build", - "dev": "tsup --watch", - "dev:debug": "DEBUG=true yarn dev" - } -} -``` - -## Project-Specific Application Patterns - -### API Fetcher Improvement - -Reference: [src/lib/defaultFetcher.ts](mdc:src/lib/defaultFetcher.ts) - -Convert existing DefaultError to Effect Data types: - -```typescript -export class NetworkError extends Data.TaggedError('NetworkError')<{ - readonly url: string; - readonly method: string; - readonly cause: unknown; -}> { - toString(): string { - if (process.env.NODE_ENV === 'production') { - return `NetworkError: Request failed`; - } - return `NetworkError: ${this.method} ${this.url} failed - ${this.cause}`; - } -} - -export class ApiError extends Data.TaggedError('ApiError')<{ - readonly errorCode: string; - readonly errorMessage: string; - readonly httpStatus: number; -}> { - toString(): string { - if (process.env.NODE_ENV === 'production') { - return `${this.errorCode}: ${this.errorMessage}`; - } - return `${this.errorCode}: ${this.errorMessage} (HTTP ${this.httpStatus})`; - } -} -``` - -### MessageService Error Handling - -Reference: [src/services/messages/messageService.ts](mdc:src/services/messages/messageService.ts) - -Convert to Effect-based error handling: - -```typescript -export class MessageValidationError extends Data.TaggedError('MessageValidationError')<{ - readonly field: string; - readonly reason: string; - readonly messageIndex?: number; -}> { - toString(): string { - if (process.env.NODE_ENV === 'production') { - return `MessageValidationError: Invalid ${this.field}`; - } - return `MessageValidationError: ${this.field} - ${this.reason}${ - this.messageIndex !== undefined ? ` (message #${this.messageIndex})` : '' - }`; - } -} - -// Utilize in MessageService.send method -send(messages: RequestSendMessagesSchema): Promise { - const effect = Effect.gen(function* (_) { - // Validation logic... - if (messageParameters.length === 0) { - return yield* _( - Effect.fail( - new MessageValidationError({ - field: 'messages', - reason: 'At least one message is required' - }) - ) - ); - } - - // ... rest of the logic - }); - - return runSafePromise(effect); -} -``` - -### Kakao Option Error Handling Improvement - -Reference: [src/models/base/kakao/kakaoOption.ts](mdc:src/models/base/kakao/kakaoOption.ts) - -Convert existing VariableValidationError to Effect Data types: - -```typescript -export class KakaoVariableError extends Data.TaggedError('KakaoVariableError')<{ - readonly invalidVariables: ReadonlyArray; - readonly operation: 'validation' | 'transformation'; -}> { - toString(): string { - if (process.env.NODE_ENV === 'production') { - return `KakaoVariableError: Invalid variable names detected`; - } - - const variableList = this.invalidVariables.map(v => `\`${v}\``).join(', '); - return `KakaoVariableError: Variable names ${variableList} cannot contain dots(.). Please use underscores(_) or other characters.`; - } -} -``` - -## Logging Strategy - -### Structured Logging - -Use structured data when logging errors: - -```typescript -const logError = (error: unknown, context: Record = {}) => { - if (process.env.NODE_ENV === 'production') { - // Production: Minimal information only - console.error({ - level: 'error', - message: formatErrorForProduction(error), - timestamp: new Date().toISOString(), - ...context, - }); - } else { - // Development: Detailed information - console.error({ - level: 'error', - error: error, - stack: error instanceof Error ? error.stack : undefined, - context, - timestamp: new Date().toISOString(), - }); - } -}; -``` - -## Usage Guide - -### Debug Build - -When problem diagnosis is needed: - -```bash -# Build in debug mode (no minify, with source maps) -DEBUG=true yarn build - -# Or run development server in debug mode -DEBUG=true yarn dev -``` - -### Error Handling Pattern - -All new errors should follow this pattern: - -1. Define as Effect Data types -2. Distinguish environment-specific messages in toString() method -3. Execute safely with runSafePromise -4. Apply structured logging - -Following this rule allows you to provide concise and readable error messages in production while maintaining sufficient debugging information in development environments. diff --git a/.cursor/rules/tdd-rules.mdc b/.cursor/rules/tdd-rules.mdc deleted file mode 100644 index 9fd23298..00000000 --- a/.cursor/rules/tdd-rules.mdc +++ /dev/null @@ -1,99 +0,0 @@ ---- -description: -globs: -alwaysApply: true ---- -# ROLE AND EXPERTISE - -You are a senior software engineer who follows Kent Beck's Test-Driven Development (TDD) and Tidy First principles. Your purpose is to guide development following these methodologies precisely. - -# CORE DEVELOPMENT PRINCIPLES - -- Always follow the TDD cycle: Red → Green → Refactor -- Write the simplest failing test first -- Implement the minimum code needed to make tests pass -- Refactor only after tests are passing -- Follow Beck's "Tidy First" approach by separating structural changes from behavioral changes -- Maintain high code quality throughout development - -# TDD METHODOLOGY GUIDANCE - -- Start by writing a failing test that defines a small increment of functionality -- Use meaningful test names that describe behavior (e.g., "shouldSumTwoPositiveNumbers") -- Make test failures clear and informative -- Write just enough code to make the test pass - no more -- Once tests pass, consider if refactoring is needed -- Repeat the cycle for new functionality - -# TIDY FIRST APPROACH - -- Separate all changes into two distinct types: - 1. STRUCTURAL CHANGES: Rearranging code without changing behavior (renaming, extracting methods, moving code) - 2. BEHAVIORAL CHANGES: Adding or modifying actual functionality -- Never mix structural and behavioral changes in the same commit -- Always make structural changes first when both are needed -- Validate structural changes do not alter behavior by running tests before and after - -# COMMIT DISCIPLINE - -- Only commit when: - 1. ALL tests are passing - 2. ALL compiler/linter warnings have been resolved - 3. The change represents a single logical unit of work - 4. Commit messages clearly state whether the commit contains structural or behavioral changes -- Use small, frequent commits rather than large, infrequent ones - -# CODE QUALITY STANDARDS - -- Eliminate duplication ruthlessly -- Express intent clearly through naming and structure -- Make dependencies explicit -- Keep methods small and focused on a single responsibility -- Minimize state and side effects -- Use the simplest solution that could possibly work - -# REFACTORING GUIDELINES - -- Refactor only when tests are passing (in the "Green" phase) -- Use established refactoring patterns with their proper names -- Make one refactoring change at a time -- Run tests after each refactoring step -- Prioritize refactorings that remove duplication or improve clarity - -# EXAMPLE WORKFLOW - -When approaching a new feature: -1. Write a simple failing test for a small part of the feature -2. Implement the bare minimum to make it pass -3. Run tests to confirm they pass (Green) -4. Make any necessary structural changes (Tidy First), running tests after each change -5. Commit structural changes separately -6. Add another test for the next small increment of functionality -7. Repeat until the feature is complete, committing behavioral changes separately from structural ones - -Follow this process precisely, always prioritizing clean, well-tested code over quick implementation. - -Always write one test at a time, make it run, then improve structure. Always run all the tests (except long-running tests) each time. - -# TypeScript-specific - -1. Prefer functional programming style over imperative style in Effect-ts(library). Use Schema library's feature instead of pattern matching with if let or match when possible. - -2. **STRICT ANY TYPE PROHIBITION**: - - NEVER use the `any` type under any circumstances - - Use `unknown` for truly unknown data types and narrow with type guards - - Use union types (`string | number`) for known possible types - - Use generic constraints (`T extends SomeInterface`) for flexible but safe typing - - Use Effect Schema for runtime type validation instead of type assertions - - If encountering third-party libraries without types, create proper type definitions or use `unknown` with validation - - Acceptable alternatives to `any`: - - `unknown` + type guards for external data - - `object` or `Record` for object types - - Generic types with constraints for reusable components - - Union types for known variations - - Effect Schema for runtime validation and type safety - -3. Check and fix wrong import path(alias) when you write code. - -4. Lint first, fix after write down code. - From 58b5abca3b6477ee7799e1cb938d2753ab425d2a Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Tue, 14 Apr 2026 14:21:50 +0900 Subject: [PATCH 06/24] =?UTF-8?q?refactor:=20=EB=A0=88=EA=B1=B0=EC=8B=9C?= =?UTF-8?q?=20=ED=81=B4=EB=9E=98=EC=8A=A4/=EC=88=98=EB=8F=99=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20Effect=20Schema=20?= =?UTF-8?q?=EC=A0=84=EB=A9=B4=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v6.0.0 Effect 전환에서 남아있던 레거시 패턴을 모두 제거: - Message, KakaoOption, RcsOption dead class 제거 (호출처 없음) - MessageType, AdditionalBody, RcsOptionRequest 수동 타입을 Schema 파생 타입으로 교체 - defaultFetcher의 try-catch를 Effect.try 파이프라인으로 변환 - barrel export 개선: VariableValidationError value export, 누락 schema 추가 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/defaultFetcher.ts | 76 +++++----- src/models/base/kakao/kakaoOption.ts | 23 +-- src/models/base/messages/message.ts | 203 +-------------------------- src/models/base/rcs/rcsOption.ts | 96 +------------ src/models/index.ts | 5 +- 5 files changed, 50 insertions(+), 353 deletions(-) diff --git a/src/lib/defaultFetcher.ts b/src/lib/defaultFetcher.ts index 683d484d..584a733d 100644 --- a/src/lib/defaultFetcher.ts +++ b/src/lib/defaultFetcher.ts @@ -85,48 +85,44 @@ const handleServerErrorResponse = (res: Response) => }), Effect.flatMap(text => { const isProduction = process.env.NODE_ENV === 'production'; + const genericError = new ServerError({ + errorCode: `HTTP_${res.status}`, + errorMessage: text.substring(0, 200) || 'Server error occurred', + httpStatus: res.status, + url: res.url, + responseBody: isProduction ? undefined : text, + }); - // JSON 파싱 시도 - try { - const json = JSON.parse(text) as Partial; - if (json.errorCode && json.errorMessage) { - return Effect.fail( - new ServerError({ - errorCode: json.errorCode, - errorMessage: json.errorMessage, - httpStatus: res.status, - url: res.url, - responseBody: isProduction ? undefined : text, - }), - ); - } - } catch (parseError) { - // SyntaxError(JSON 파싱 실패)는 fallback으로 진행, 그 외 예외는 즉시 반환 - if (!(parseError instanceof SyntaxError)) { - return Effect.fail( - new ServerError({ - errorCode: 'ResponseParseError', - errorMessage: - parseError instanceof Error - ? parseError.message - : String(parseError), - httpStatus: res.status, - url: res.url, - responseBody: isProduction ? undefined : text, - }), - ); - } - } - - // JSON이 아니거나 필드가 없는 경우 - return Effect.fail( - new ServerError({ - errorCode: `HTTP_${res.status}`, - errorMessage: text.substring(0, 200) || 'Server error occurred', - httpStatus: res.status, - url: res.url, - responseBody: isProduction ? undefined : text, + return pipe( + Effect.try({ + try: () => JSON.parse(text) as Partial, + catch: parseError => + parseError instanceof SyntaxError + ? genericError + : new ServerError({ + errorCode: 'ResponseParseError', + errorMessage: + parseError instanceof Error + ? parseError.message + : String(parseError), + httpStatus: res.status, + url: res.url, + responseBody: isProduction ? undefined : text, + }), }), + Effect.flatMap(json => + Effect.fail( + json.errorCode && json.errorMessage + ? new ServerError({ + errorCode: json.errorCode, + errorMessage: json.errorMessage, + httpStatus: res.status, + url: res.url, + responseBody: isProduction ? undefined : text, + }) + : genericError, + ), + ), ); }), ); diff --git a/src/models/base/kakao/kakaoOption.ts b/src/models/base/kakao/kakaoOption.ts index 2060a9b8..74fccd58 100644 --- a/src/models/base/kakao/kakaoOption.ts +++ b/src/models/base/kakao/kakaoOption.ts @@ -1,6 +1,5 @@ import {runSafeSync} from '@lib/effectErrorHandler'; import {Data, Effect, Array as EffectArray, pipe, Schema} from 'effect'; -import {type KakaoOptionRequest} from '../../requests/kakao/kakaoOptionRequest'; import { bmsButtonSchema, bmsCarouselCommerceSchema, @@ -11,7 +10,7 @@ import { bmsSubWideItemSchema, bmsVideoSchema, } from './bms'; -import {KakaoButton, kakaoButtonSchema} from './kakaoButton'; +import {kakaoButtonSchema} from './kakaoButton'; // Effect Data 타입을 활용한 에러 클래스 export class VariableValidationError extends Data.TaggedError( @@ -206,23 +205,3 @@ export const baseKakaoOptionSchema = Schema.Struct({ buttons: Schema.optional(Schema.Array(kakaoButtonSchema)), bms: Schema.optional(kakaoOptionBmsSchema), }); - -export class KakaoOption { - pfId: string; - templateId?: string; - variables?: Record; - disableSms?: boolean; - adFlag?: boolean; - buttons?: ReadonlyArray; - imageId?: string; - - constructor(parameter: KakaoOptionRequest) { - this.pfId = parameter.pfId; - this.templateId = parameter.templateId; - this.variables = parameter.variables; - this.disableSms = parameter.disableSms; - this.adFlag = parameter.adFlag; - this.buttons = parameter.buttons; - this.imageId = parameter.imageId; - } -} diff --git a/src/models/base/messages/message.ts b/src/models/base/messages/message.ts index 1630c100..ee00f7f4 100644 --- a/src/models/base/messages/message.ts +++ b/src/models/base/messages/message.ts @@ -1,86 +1,9 @@ -import { - baseKakaoOptionSchema, - KakaoOption, -} from '@models/base/kakao/kakaoOption'; +import {baseKakaoOptionSchema} from '@models/base/kakao/kakaoOption'; import {naverOptionSchema} from '@models/base/naver/naverOption'; -import {RcsOption, rcsOptionSchema} from '@models/base/rcs/rcsOption'; -import {FileIds} from '@models/requests/messages/groupMessageRequest'; +import {rcsOptionSchema} from '@models/base/rcs/rcsOption'; import {Schema} from 'effect'; -import { - VoiceOptionSchema, - voiceOptionSchema, -} from '@/models/requests/voice/voiceOption'; +import {voiceOptionSchema} from '@/models/requests/voice/voiceOption'; -/** - * @name MessageType 메시지 유형(단문 문자, 장문 문자, 알림톡 등) - * SMS: 단문 문자 - * LMS: 장문 문자 - * MMS: 사진 문자 - * ATA: 알림톡 - * CTA: 친구톡 - * CTI: 사진 한장이 포함된 친구톡 - * NSA: 네이버 스마트알림(톡톡) - * RCS_SMS: RCS 단문 문자 - * RCS_LMS: RCS 장문 문자 - * RCS_MMS: RCS 사진 문자 - * RCS_TPL: RCS 템플릿 - * RCS_ITPL: RCS 이미지 템플릿 - * RCS_LTPL: RCS LMS 템플릿 문자 - * FAX: 팩스 - * VOICE: 음성문자(TTS) - */ -export type MessageType = - | 'SMS' - | 'LMS' - | 'MMS' - | 'ATA' - | 'CTA' - | 'CTI' - | 'NSA' - | 'RCS_SMS' - | 'RCS_LMS' - | 'RCS_MMS' - | 'RCS_TPL' - | 'RCS_ITPL' - | 'RCS_LTPL' - | 'FAX' - | 'VOICE' - | 'BMS_TEXT' - | 'BMS_IMAGE' - | 'BMS_WIDE' - | 'BMS_WIDE_ITEM_LIST' - | 'BMS_CAROUSEL_FEED' - | 'BMS_PREMIUM_VIDEO' - | 'BMS_COMMERCE' - | 'BMS_CAROUSEL_COMMERCE' - | 'BMS_FREE'; - -/** - * 메시지 타입 -SMS: 단문 문자 -LMS: 장문 문자 -MMS: 사진 문자 -ATA: 알림톡 -CTA: 친구톡 -CTI: 친구톡 + 이미지 -NSA: 네이버 스마트 알림 -RCS_SMS: RCS 단문 문자 -RCS_LMS: RCS 장문 문자 -RCS_MMS: RCS 사진 문자 -RCS_TPL: RCS 템플릿 문자 -RCS_ITPL: RCS 이미지 템플릿 문자 -RCS_LTPL: RCS LMS 템플릿 문자 -FAX: 팩스 -VOICE: 보이스콜 -BMS_TEXT: 브랜드 메시지 텍스트형 -BMS_IMAGE: 브랜드 메시지 이미지형 -BMS_WIDE: 브랜드 메시지 와이드형 -BMS_WIDE_ITEM_LIST: 브랜드 메시지 와이드 아이템 리스트형 -BMS_CAROUSEL_FEED: 브랜드 메시지 캐러셀 피드형 -BMS_PREMIUM_VIDEO: 브랜드 메시지 프리미엄 비디오형 -BMS_COMMERCE: 브랜드 메시지 커머스형 -BMS_CAROUSEL_COMMERCE: 브랜드 메시지 캐러셀 커머스형 - */ export const messageTypeSchema = Schema.Literal( 'SMS', 'LMS', @@ -108,6 +31,8 @@ export const messageTypeSchema = Schema.Literal( 'BMS_FREE', ); +export type MessageType = Schema.Schema.Type; + export const messageSchema = Schema.Struct({ to: Schema.Union(Schema.String, Schema.Array(Schema.String)), from: Schema.optional(Schema.String), @@ -131,121 +56,3 @@ export const messageSchema = Schema.Struct({ }); export type MessageSchema = Schema.Schema.Type; - -/** - * 메시지 모델, 전체적인 메시지 발송을 위한 파라미터는 이 Message 모델에서 관장함 - */ -export class Message { - /** - * 수신번호 - */ - to: string | ReadonlyArray; - - /** - * 발신번호 - */ - from?: string; - - /** - * 메시지 내용 - */ - text?: string; - - /** - * 메시지 생성일자 - */ - dateCreated?: string; - - /** - * 메시지 수정일자 - */ - dateUpdated?: string; - - /** - * 메시지의 그룹 ID - */ - groupId?: string; - - /** - * 해당 메시지의 ID - */ - messageId?: string; - - /** - * MMS 전용 스토리지(이미지) ID - */ - imageId?: string; - - /** - * @name MessageType 메시지 유형 - */ - type?: MessageType; - - /** - * 문자 제목(LMS, MMS 전용) - */ - subject?: string; - - /** - * 메시지 타입 감지 여부(비활성화 시 반드시 타입이 명시 되어야 함) - */ - autoTypeDetect?: boolean; - - /** - * 카카오 알림톡/친구톡을 위한 프로퍼티 - */ - kakaoOptions?: KakaoOption; - - /** - * RCS 메시지를 위한 프로퍼티 - */ - rcsOptions?: RcsOption; - - /** - * 해외 문자 발송을 위한 국가번호(예) "82", "1" 등) - */ - country?: string; - - /** - * 메시지 로그 - */ - log?: ReadonlyArray; - replacements?: ReadonlyArray; - - /** - * 메시지 상태 코드 - * @see https://developers.solapi.com/references/message-status-codes - */ - statusCode?: string; - - /** - * 사용자를 위한 사용자만의 커스텀 값을 입력할 수 있는 필드 - * 단, 오브젝트 내 키 값 모두 문자열 형태로 입력되어야 합니다. - */ - customFields?: Record; - - faxOptions?: FileIds; - - voiceOptions?: VoiceOptionSchema; - - constructor(parameter: MessageSchema) { - this.to = parameter.to; - this.from = parameter.from; - this.text = parameter.text; - this.imageId = parameter.imageId; - this.type = parameter.type; - this.subject = parameter.subject; - this.autoTypeDetect = parameter.autoTypeDetect; - this.country = parameter.country; - if (parameter.kakaoOptions != undefined) { - this.kakaoOptions = new KakaoOption(parameter.kakaoOptions); - } - if (parameter.rcsOptions != undefined) { - this.rcsOptions = new RcsOption(parameter.rcsOptions); - } - this.customFields = parameter.customFields; - this.replacements = parameter.replacements; - this.faxOptions = parameter.faxOptions; - this.voiceOptions = parameter.voiceOptions; - } -} diff --git a/src/models/base/rcs/rcsOption.ts b/src/models/base/rcs/rcsOption.ts index 384dbf48..ca3e92cd 100644 --- a/src/models/base/rcs/rcsOption.ts +++ b/src/models/base/rcs/rcsOption.ts @@ -1,29 +1,5 @@ import {Schema} from 'effect'; -import {RcsButton, rcsButtonSchema} from './rcsButton'; - -/** - * RCS 사진문자 발송 시 필요한 오브젝트 - */ -export type AdditionalBody = { - /** - * 슬라이드 제목 - */ - title: string; - /** - * 슬라이드 설명 - */ - description: string; - /** - * MMS 발송 시 사용되는 이미지의 고유 아이디. 이미지 타입이 MMS일 경우에만 사용 가능합니다. - * @see https://console.solapi.com/storage - * @see https://developers.solapi.com/references/storage - */ - imaggeId?: string; - /** - * 슬라이드에 추가되는 버튼 목록, 최대 2개 - */ - buttons?: ReadonlyArray; -}; +import {rcsButtonSchema} from './rcsButton'; export const additionalBodySchema = Schema.Struct({ title: Schema.String, @@ -32,48 +8,7 @@ export const additionalBodySchema = Schema.Struct({ buttons: Schema.optional(Schema.Array(rcsButtonSchema)), }); -/** - * RCS 발송을 위한 파라미터 타입 - */ -export type RcsOptionRequest = { - /** - * RCS 채널의 브랜드 ID - */ - brandId: string; - /** - * RCS 템플릿 ID - */ - templateId?: string; - /** - * 문자 복사 가능 여부 - */ - copyAllowed?: boolean; - /** - * RCS 템플릿 대체 문구 입력 오브젝트 - * 예) { #{치환문구1} : "치환문구 값" } - */ - variables?: Record; - /** - * 사진 문자 타입. 타입: "M3", "S3", "M4", "S4", "M5", "S5", "M6", "S6" (M: 중간 사이즈. S: 작은 사이즈. 숫자: 사진 개수) - */ - mmsType?: 'M3' | 'S3' | 'M4' | 'S4' | 'M5' | 'S5' | 'M6' | 'S6'; - /** - * 광고 문자 여부 - */ - commercialType?: boolean; - /** - * 대체발송여부. false 로 설정했을 경우 해당건이 발송에 실패하게 됐을 때 문자로(SMS, LMS, MMS)로 대체 발송됩니다. 대체 발송이 될 경우 기존 가격은 환불되고 각 문자 타입에 맞는 금액이 차감됩니다. 기본값: false - */ - disableSms?: boolean; - /** - * RCS 사진 문자 전송 시 필요한 오브젝트 - */ - additionalBody?: AdditionalBody; - /** - * RCS 템플릿 버튼 배열 - */ - buttons?: ReadonlyArray; -}; +export type AdditionalBody = Schema.Schema.Type; export const rcsOptionRequestSchema = Schema.Struct({ brandId: Schema.String, @@ -93,28 +28,7 @@ export const rcsOptionRequestSchema = Schema.Struct({ export const rcsOptionSchema = rcsOptionRequestSchema; +export type RcsOptionRequest = Schema.Schema.Type< + typeof rcsOptionRequestSchema +>; export type RcsOptionSchema = Schema.Schema.Type; - -export class RcsOption { - brandId: string; - templateId?: string; - copyAllowed?: boolean; - variables?: Record; - mmsType?: 'M3' | 'S3' | 'M4' | 'S4' | 'M5' | 'S5' | 'M6' | 'S6'; // (M: 중간 사이즈. S: 작은 사이즈. 숫자: 사진 개수) - commercialType?: boolean; - disableSms?: boolean; - additionalBody?: AdditionalBody; - buttons?: ReadonlyArray; - - constructor(parameter: RcsOptionRequest) { - this.brandId = parameter.brandId; - this.templateId = parameter.templateId; - this.copyAllowed = parameter.copyAllowed; - this.mmsType = parameter.mmsType; - this.commercialType = parameter.commercialType; - this.variables = parameter.variables; - this.disableSms = parameter.disableSms; - this.additionalBody = parameter.additionalBody; - this.buttons = parameter.buttons; - } -} diff --git a/src/models/index.ts b/src/models/index.ts index 740bca60..9cbff4db 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -62,11 +62,10 @@ export { bmsChatBubbleTypeSchema, type KakaoOptionBmsSchema, transformVariables, - type VariableValidationError, + VariableValidationError, validateVariableNames, } from './base/kakao/kakaoOption'; export { - type Message, type MessageSchema, type MessageType, messageSchema, @@ -86,9 +85,11 @@ export { } from './base/rcs/rcsButton'; export { type AdditionalBody, + additionalBodySchema, type RcsOptionRequest, type RcsOptionSchema, rcsOptionRequestSchema, + rcsOptionSchema, } from './base/rcs/rcsOption'; // Requests From 6e149efd6377a156c2b16d092701bb7ddf3c9530 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Tue, 14 Apr 2026 14:32:14 +0900 Subject: [PATCH 07/24] =?UTF-8?q?fix:=20handleServerErrorResponse=20null?= =?UTF-8?q?=20JSON=20=EB=B0=A9=EC=96=B4=20=EB=B0=8F=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EA=B0=84=EA=B2=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - JSON.parse("null") 시 null.errorCode TypeError 방지 (null guard 추가) - parseServerErrorBody 순수 함수 추출로 에러 결정 로직 명확화 - makeError 팩토리로 ServerError 생성 코드 중복 제거 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/defaultFetcher.ts | 81 +++++++++++++++++++++------------------ 1 file changed, 44 insertions(+), 37 deletions(-) diff --git a/src/lib/defaultFetcher.ts b/src/lib/defaultFetcher.ts index 584a733d..6d5bb77a 100644 --- a/src/lib/defaultFetcher.ts +++ b/src/lib/defaultFetcher.ts @@ -69,6 +69,34 @@ const handleClientErrorResponse = (res: Response) => ), ); +/** + * JSON 파싱을 시도하여 적절한 ServerError를 결정하는 순수 함수. + * 모든 경로가 ServerError를 반환한다 (서버 에러 응답이므로 성공 경로 없음). + */ +function parseServerErrorBody( + text: string, + genericError: ServerError, + makeError: (errorCode: string, errorMessage: string) => ServerError, +): ServerError { + let json: Partial; + try { + json = JSON.parse(text) as Partial; + } catch (parseError) { + if (parseError instanceof SyntaxError) { + return genericError; + } + return makeError( + 'ResponseParseError', + parseError instanceof Error ? parseError.message : String(parseError), + ); + } + + if (json != null && json.errorCode && json.errorMessage) { + return makeError(json.errorCode, json.errorMessage); + } + return genericError; +} + const handleServerErrorResponse = (res: Response) => pipe( Effect.tryPromise({ @@ -85,45 +113,24 @@ const handleServerErrorResponse = (res: Response) => }), Effect.flatMap(text => { const isProduction = process.env.NODE_ENV === 'production'; - const genericError = new ServerError({ - errorCode: `HTTP_${res.status}`, - errorMessage: text.substring(0, 200) || 'Server error occurred', - httpStatus: res.status, - url: res.url, - responseBody: isProduction ? undefined : text, - }); + const makeError = ( + errorCode: string, + errorMessage: string, + ): ServerError => + new ServerError({ + errorCode, + errorMessage, + httpStatus: res.status, + url: res.url, + responseBody: isProduction ? undefined : text, + }); - return pipe( - Effect.try({ - try: () => JSON.parse(text) as Partial, - catch: parseError => - parseError instanceof SyntaxError - ? genericError - : new ServerError({ - errorCode: 'ResponseParseError', - errorMessage: - parseError instanceof Error - ? parseError.message - : String(parseError), - httpStatus: res.status, - url: res.url, - responseBody: isProduction ? undefined : text, - }), - }), - Effect.flatMap(json => - Effect.fail( - json.errorCode && json.errorMessage - ? new ServerError({ - errorCode: json.errorCode, - errorMessage: json.errorMessage, - httpStatus: res.status, - url: res.url, - responseBody: isProduction ? undefined : text, - }) - : genericError, - ), - ), + const genericError = makeError( + `HTTP_${res.status}`, + text.substring(0, 200) || 'Server error occurred', ); + + return Effect.fail(parseServerErrorBody(text, genericError, makeError)); }), ); From e33df239765a443ef094543c718977d2818e1a33 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Tue, 14 Apr 2026 14:34:42 +0900 Subject: [PATCH 08/24] =?UTF-8?q?fix:=20handleClientErrorResponse=EC=97=90?= =?UTF-8?q?=20=EB=8F=99=EC=9D=BC=ED=95=9C=20null/=EB=B9=84=EC=A0=95?= =?UTF-8?q?=ED=98=95=20JSON=20=EB=B0=A9=EC=96=B4=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - res.json() 대신 res.text() + JSON.parse로 변경하여 파싱 실패 처리 - null, 비객체 JSON, 다른 스키마 JSON에 대한 null guard 추가 - genericError fallback으로 "undefined: undefined" 에러 메시지 방지 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/defaultFetcher.ts | 38 +++++++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/src/lib/defaultFetcher.ts b/src/lib/defaultFetcher.ts index 6d5bb77a..f8cf40d6 100644 --- a/src/lib/defaultFetcher.ts +++ b/src/lib/defaultFetcher.ts @@ -46,7 +46,7 @@ const handleOkResponse = (res: Response) => const handleClientErrorResponse = (res: Response) => pipe( Effect.tryPromise({ - try: () => res.json() as Promise, + try: () => res.text(), catch: e => new DefaultError({ errorCode: 'ParseError', @@ -57,16 +57,32 @@ const handleClientErrorResponse = (res: Response) => }, }), }), - Effect.flatMap(error => - Effect.fail( - new ClientError({ - errorCode: error.errorCode, - errorMessage: error.errorMessage, - httpStatus: res.status, - url: res.url, - }), - ), - ), + Effect.flatMap(text => { + const genericError = new ClientError({ + errorCode: `HTTP_${res.status}`, + errorMessage: text.substring(0, 200) || 'Client error occurred', + httpStatus: res.status, + url: res.url, + }); + + let json: Partial; + try { + json = JSON.parse(text) as Partial; + } catch { + return Effect.fail(genericError); + } + + return Effect.fail( + json != null && json.errorCode && json.errorMessage + ? new ClientError({ + errorCode: json.errorCode, + errorMessage: json.errorMessage, + httpStatus: res.status, + url: res.url, + }) + : genericError, + ); + }), ); /** From 2589f110b6d7e2e93322345abcd3c96f2a213723 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Tue, 14 Apr 2026 15:08:38 +0900 Subject: [PATCH 09/24] =?UTF-8?q?refactor:=20CLAUDE.md=20=EC=9B=90?= =?UTF-8?q?=EC=B9=99=20=EC=9C=84=EB=B0=98=20=EC=A0=95=EB=A6=AC=20=EB=B0=8F?= =?UTF-8?q?=20dead=20code=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - defaultFetcher.ts: Effect.flatMap 내 try-catch를 Effect.try로 전환, throw new Error 제거, isErrorResponse 타입 가드로 as Partial 단언 대체 - defaultError.ts: 미사용 deprecated ApiError alias 제거, isErrorResponse 타입 가드 추가 - index.ts/messageService.ts: sendOne 메소드 삭제 - kakaoOption.ts: as Record 단언을 keyof BaseBmsSchemaType으로 대체 - defaultService.ts: 내부 전용 타입 불필요 export 제거 - stringDateTrasnfer.ts → stringDateTransfer.ts 파일명 오타 수정 및 전체 import 경로 반영 Co-Authored-By: Claude Opus 4.6 (1M context) --- AGENTS.md | 2 +- src/errors/defaultError.ts | 13 +- src/index.ts | 8 - src/lib/defaultFetcher.ts | 137 +++++++++++------- src/lib/schemaUtils.ts | 2 +- ...gDateTrasnfer.ts => stringDateTransfer.ts} | 0 src/models/base/kakao/kakaoOption.ts | 8 +- src/models/requests/iam/getBlacksRequest.ts | 2 +- .../kakao/getKakaoAlimtalkTemplatesRequest.ts | 2 +- .../requests/kakao/getKakaoChannelsRequest.ts | 2 +- .../requests/messages/getGroupsRequest.ts | 2 +- .../requests/messages/getMessagesRequest.ts | 2 +- .../requests/messages/getStatisticsRequest.ts | 2 +- src/models/requests/messages/requestConfig.ts | 2 +- src/services/defaultService.ts | 4 +- src/services/messages/messageService.ts | 32 ---- 16 files changed, 106 insertions(+), 114 deletions(-) rename src/lib/{stringDateTrasnfer.ts => stringDateTransfer.ts} (100%) diff --git a/AGENTS.md b/AGENTS.md index 92db97a5..f5572936 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,7 +120,7 @@ Schema.String.pipe( | `authenticator.ts` | HMAC-SHA256 auth header | | `stringifyQuery.ts` | URL query string builder (array handling) | | `fileToBase64.ts` | File/URL → Base64 | -| `stringDateTrasnfer.ts` | Date parsing with `InvalidDateError` | +| `stringDateTransfer.ts` | Date parsing with `InvalidDateError` | ## Anti-Patterns diff --git a/src/errors/defaultError.ts b/src/errors/defaultError.ts index 9604a0e0..cf47e4da 100644 --- a/src/errors/defaultError.ts +++ b/src/errors/defaultError.ts @@ -109,11 +109,6 @@ export class ClientError extends Data.TaggedError('ClientError')<{ } } -/** @deprecated Use ClientError instead */ -export const ApiError = ClientError; -/** @deprecated Use ClientError instead */ -export type ApiError = ClientError; - // Defect(예측되지 않은 예외) — Effect 경계에서 발생하는 비정상 에러 export class UnexpectedDefectError extends Data.TaggedError( 'UnexpectedDefectError', @@ -156,3 +151,11 @@ URL: ${this.url} Response: ${this.responseBody?.substring(0, 500) ?? '(empty)'}`; } } + +export const isErrorResponse = (value: unknown): value is ErrorResponse => + value != null && + typeof value === 'object' && + 'errorCode' in value && + typeof (value as Record).errorCode === 'string' && + 'errorMessage' in value && + typeof (value as Record).errorMessage === 'string'; diff --git a/src/index.ts b/src/index.ts index a189aa77..73289936 100644 --- a/src/index.ts +++ b/src/index.ts @@ -218,14 +218,6 @@ export class SolapiMessageService { readonly removeGroup: typeof GroupService.prototype.removeGroup; // MessageService 위임 - /** - * 단일 메시지 발송 기능 - * @param message 메시지(문자, 알림톡 등) - * @param appId appstore용 app id - */ - // TODO: temporary remove - readonly sendOne: typeof MessageService.prototype.sendOne; - /** * 메시지 발송 기능, sendMany 함수보다 개선된 오류 표시 기능등을 제공합니다. * 한번의 요청으로 최대 10,000건까지 발송할 수 있습니다. diff --git a/src/lib/defaultFetcher.ts b/src/lib/defaultFetcher.ts index f8cf40d6..f8a05730 100644 --- a/src/lib/defaultFetcher.ts +++ b/src/lib/defaultFetcher.ts @@ -3,7 +3,7 @@ import { ApiKeyError, ClientError, DefaultError, - ErrorResponse, + isErrorResponse, NetworkError, ServerError, } from '../errors/defaultError'; @@ -21,27 +21,49 @@ class RetryableError extends Data.TaggedError('RetryableError')<{ }> {} const handleOkResponse = (res: Response) => - Effect.tryPromise({ - try: async (): Promise => { - const responseText = await res.text(); + pipe( + Effect.tryPromise({ + try: () => res.text(), + catch: e => + new DefaultError({ + errorCode: 'ParseError', + errorMessage: e instanceof Error ? e.message : String(e), + context: { + responseStatus: res.status, + responseUrl: res.url, + }, + }), + }), + Effect.flatMap(responseText => { if (!responseText) { if (res.status === 204) { - return {} as R; + return Effect.succeed({} as R); } - throw new Error('API returned empty response body'); + return Effect.fail( + new DefaultError({ + errorCode: 'ParseError', + errorMessage: 'API returned empty response body', + context: { + responseStatus: res.status, + responseUrl: res.url, + }, + }), + ); } - return JSON.parse(responseText) as R; - }, - catch: e => - new DefaultError({ - errorCode: 'ParseError', - errorMessage: e instanceof Error ? e.message : String(e), - context: { - responseStatus: res.status, - responseUrl: res.url, - }, - }), - }); + return Effect.try({ + try: () => JSON.parse(responseText) as R, + catch: e => + new DefaultError({ + errorCode: 'ParseError', + errorMessage: e instanceof Error ? e.message : String(e), + context: { + responseStatus: res.status, + responseUrl: res.url, + }, + }), + }); + }), + ); const handleClientErrorResponse = (res: Response) => pipe( @@ -65,52 +87,57 @@ const handleClientErrorResponse = (res: Response) => url: res.url, }); - let json: Partial; - try { - json = JSON.parse(text) as Partial; - } catch { - return Effect.fail(genericError); - } - - return Effect.fail( - json != null && json.errorCode && json.errorMessage - ? new ClientError({ - errorCode: json.errorCode, - errorMessage: json.errorMessage, - httpStatus: res.status, - url: res.url, - }) - : genericError, + return pipe( + Effect.try({ + try: () => JSON.parse(text) as unknown, + catch: () => genericError, + }), + Effect.flatMap(json => + Effect.fail( + isErrorResponse(json) + ? new ClientError({ + errorCode: json.errorCode, + errorMessage: json.errorMessage, + httpStatus: res.status, + url: res.url, + }) + : genericError, + ), + ), ); }), ); /** - * JSON 파싱을 시도하여 적절한 ServerError를 결정하는 순수 함수. - * 모든 경로가 ServerError를 반환한다 (서버 에러 응답이므로 성공 경로 없음). + * JSON 파싱을 시도하여 적절한 ServerError로 실패하는 Effect를 반환. + * 모든 경로가 ServerError로 실패한다 (서버 에러 응답이므로 성공 경로 없음). */ function parseServerErrorBody( text: string, genericError: ServerError, makeError: (errorCode: string, errorMessage: string) => ServerError, -): ServerError { - let json: Partial; - try { - json = JSON.parse(text) as Partial; - } catch (parseError) { - if (parseError instanceof SyntaxError) { - return genericError; - } - return makeError( - 'ResponseParseError', - parseError instanceof Error ? parseError.message : String(parseError), - ); - } - - if (json != null && json.errorCode && json.errorMessage) { - return makeError(json.errorCode, json.errorMessage); - } - return genericError; +): Effect.Effect { + return pipe( + Effect.try({ + try: () => JSON.parse(text) as unknown, + catch: (parseError: unknown) => + parseError instanceof SyntaxError + ? genericError + : makeError( + 'ResponseParseError', + parseError instanceof Error + ? parseError.message + : String(parseError), + ), + }), + Effect.flatMap(json => + Effect.fail( + isErrorResponse(json) + ? makeError(json.errorCode, json.errorMessage) + : genericError, + ), + ), + ); } const handleServerErrorResponse = (res: Response) => @@ -146,7 +173,7 @@ const handleServerErrorResponse = (res: Response) => text.substring(0, 200) || 'Server error occurred', ); - return Effect.fail(parseServerErrorBody(text, genericError, makeError)); + return parseServerErrorBody(text, genericError, makeError); }), ); diff --git a/src/lib/schemaUtils.ts b/src/lib/schemaUtils.ts index 0d1c958f..f77f6bef 100644 --- a/src/lib/schemaUtils.ts +++ b/src/lib/schemaUtils.ts @@ -1,7 +1,7 @@ import {Schema} from 'effect'; import * as Effect from 'effect/Effect'; import {BadRequestError, InvalidDateError} from '../errors/defaultError'; -import stringDateTransfer, {formatWithTransfer} from './stringDateTrasnfer'; +import stringDateTransfer, {formatWithTransfer} from './stringDateTransfer'; /** * Schema 디코딩 + BadRequestError 변환을 결합한 Effect 헬퍼. diff --git a/src/lib/stringDateTrasnfer.ts b/src/lib/stringDateTransfer.ts similarity index 100% rename from src/lib/stringDateTrasnfer.ts rename to src/lib/stringDateTransfer.ts diff --git a/src/models/base/kakao/kakaoOption.ts b/src/models/base/kakao/kakaoOption.ts index 74fccd58..081b3ac3 100644 --- a/src/models/base/kakao/kakaoOption.ts +++ b/src/models/base/kakao/kakaoOption.ts @@ -53,7 +53,10 @@ export type BmsChatBubbleType = Schema.Schema.Type< * - WIDE_ITEM_LIST: header, mainWideItem, subWideItemList 필수 * - COMMERCE: imageId, commerce, buttons 필수 */ -const BMS_REQUIRED_FIELDS: Record> = { +const BMS_REQUIRED_FIELDS: Record< + BmsChatBubbleType, + ReadonlyArray +> = { TEXT: [], IMAGE: ['imageId'], WIDE: ['imageId'], @@ -107,9 +110,8 @@ const validateBmsRequiredFields = ( ): boolean | string => { const chatBubbleType = bms.chatBubbleType; const requiredFields = BMS_REQUIRED_FIELDS[chatBubbleType] ?? []; - const bmsRecord = bms as Record; const missingFields = requiredFields.filter( - field => bmsRecord[field] === undefined || bmsRecord[field] === null, + field => bms[field] === undefined || bms[field] === null, ); if (missingFields.length > 0) { diff --git a/src/models/requests/iam/getBlacksRequest.ts b/src/models/requests/iam/getBlacksRequest.ts index dadcbcbd..5351fd49 100644 --- a/src/models/requests/iam/getBlacksRequest.ts +++ b/src/models/requests/iam/getBlacksRequest.ts @@ -1,4 +1,4 @@ -import {formatWithTransfer} from '@lib/stringDateTrasnfer'; +import {formatWithTransfer} from '@lib/stringDateTransfer'; import {Schema} from 'effect'; import {type DatePayloadType} from '../common/datePayload'; diff --git a/src/models/requests/kakao/getKakaoAlimtalkTemplatesRequest.ts b/src/models/requests/kakao/getKakaoAlimtalkTemplatesRequest.ts index 982f77df..902d3be8 100644 --- a/src/models/requests/kakao/getKakaoAlimtalkTemplatesRequest.ts +++ b/src/models/requests/kakao/getKakaoAlimtalkTemplatesRequest.ts @@ -1,4 +1,4 @@ -import {formatWithTransfer} from '@lib/stringDateTrasnfer'; +import {formatWithTransfer} from '@lib/stringDateTransfer'; import { type KakaoAlimtalkTemplateStatus, kakaoAlimtalkTemplateStatusSchema, diff --git a/src/models/requests/kakao/getKakaoChannelsRequest.ts b/src/models/requests/kakao/getKakaoChannelsRequest.ts index 10fd502b..406fcf99 100644 --- a/src/models/requests/kakao/getKakaoChannelsRequest.ts +++ b/src/models/requests/kakao/getKakaoChannelsRequest.ts @@ -1,4 +1,4 @@ -import {formatWithTransfer} from '@lib/stringDateTrasnfer'; +import {formatWithTransfer} from '@lib/stringDateTransfer'; import {Schema} from 'effect'; import {type DatePayloadType} from '../common/datePayload'; diff --git a/src/models/requests/messages/getGroupsRequest.ts b/src/models/requests/messages/getGroupsRequest.ts index 9c75f6c3..c25eb858 100644 --- a/src/models/requests/messages/getGroupsRequest.ts +++ b/src/models/requests/messages/getGroupsRequest.ts @@ -1,4 +1,4 @@ -import {formatWithTransfer} from '@lib/stringDateTrasnfer'; +import {formatWithTransfer} from '@lib/stringDateTransfer'; import {Schema} from 'effect'; export const getGroupsRequestSchema = Schema.Struct({ diff --git a/src/models/requests/messages/getMessagesRequest.ts b/src/models/requests/messages/getMessagesRequest.ts index 5b2e793b..ff2dc3f3 100644 --- a/src/models/requests/messages/getMessagesRequest.ts +++ b/src/models/requests/messages/getMessagesRequest.ts @@ -1,4 +1,4 @@ -import {formatWithTransfer} from '@lib/stringDateTrasnfer'; +import {formatWithTransfer} from '@lib/stringDateTransfer'; import {Schema} from 'effect'; import {messageTypeSchema} from '../../base/messages/message'; diff --git a/src/models/requests/messages/getStatisticsRequest.ts b/src/models/requests/messages/getStatisticsRequest.ts index 5900ec38..288ce8b1 100644 --- a/src/models/requests/messages/getStatisticsRequest.ts +++ b/src/models/requests/messages/getStatisticsRequest.ts @@ -1,4 +1,4 @@ -import {formatWithTransfer} from '@lib/stringDateTrasnfer'; +import {formatWithTransfer} from '@lib/stringDateTransfer'; import {Schema} from 'effect'; export const getStatisticsRequestSchema = Schema.Struct({ diff --git a/src/models/requests/messages/requestConfig.ts b/src/models/requests/messages/requestConfig.ts index 9e96c865..6a196f88 100644 --- a/src/models/requests/messages/requestConfig.ts +++ b/src/models/requests/messages/requestConfig.ts @@ -1,4 +1,4 @@ -import {formatWithTransfer} from '@lib/stringDateTrasnfer'; +import {formatWithTransfer} from '@lib/stringDateTransfer'; import {Schema} from 'effect'; import pkg from '../../../../package.json'; diff --git a/src/services/defaultService.ts b/src/services/defaultService.ts index 5e60865d..4758f89b 100644 --- a/src/services/defaultService.ts +++ b/src/services/defaultService.ts @@ -10,12 +10,12 @@ import type { ServerError, } from '../errors/defaultError'; -export type RequestConfig = { +type RequestConfig = { method: string; url: string; }; -export type DefaultServiceParameter = { +type DefaultServiceParameter = { httpMethod: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; url: string; body?: T; diff --git a/src/services/messages/messageService.ts b/src/services/messages/messageService.ts index 02781166..453111ae 100644 --- a/src/services/messages/messageService.ts +++ b/src/services/messages/messageService.ts @@ -19,15 +19,11 @@ import { type MultipleMessageSendingRequestSchema, multipleMessageSendingRequestSchema, type RequestSendMessagesSchema, - type RequestSendOneMessageSchema, requestSendMessageSchema, - type SingleMessageSendingRequestSchema, - singleMessageSendingRequestSchema, } from '@models/requests/messages/sendMessage'; import { GetMessagesResponse, GetStatisticsResponse, - SingleMessageSentResponse, } from '@models/responses/messageResponses'; import {DetailGroupMessageResponse} from '@models/responses/sendManyDetailResponse'; import * as Effect from 'effect/Effect'; @@ -38,34 +34,6 @@ import { import DefaultService from '../defaultService'; export default class MessageService extends DefaultService { - /** - * 단일 메시지 발송 기능 - * @param message 메시지(문자, 알림톡 등) - * @param appId appstore용 app id - */ - async sendOne( - message: RequestSendOneMessageSchema, - appId?: string, - ): Promise { - return runSafePromise( - Effect.flatMap( - decodeWithBadRequest(singleMessageSendingRequestSchema, { - message, - ...(appId ? {agent: {appId}} : {}), - }), - parameter => - this.requestEffect< - SingleMessageSendingRequestSchema, - SingleMessageSentResponse - >({ - httpMethod: 'POST', - url: 'messages/v4/send', - body: parameter, - }), - ), - ); - } - /** * 메시지 발송 기능, sendMany 함수보다 개선된 오류 표시 기능등을 제공합니다. * 한번의 요청으로 최대 10,000건까지 발송할 수 있습니다. From 0d9d7b45184f8cd81474f19b1e72beb5b9d63bfd Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Tue, 14 Apr 2026 15:28:59 +0900 Subject: [PATCH 10/24] =?UTF-8?q?fix:=20=EB=A6=AC=EB=B7=B0=20=ED=94=BC?= =?UTF-8?q?=EB=93=9C=EB=B0=B1=20=EB=B0=98=EC=98=81=20=E2=80=94=20isErrorRe?= =?UTF-8?q?sponse=20=EA=B0=95=ED=99=94,=20=EC=97=90=EB=9F=AC=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EC=9D=BC=EA=B4=80=EC=84=B1,=20examples=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isErrorResponse: 빈 문자열 거부 추가 (기존 truthy 체크 동작 보존), 단일 cast로 간소화 - handleClientErrorResponse: SyntaxError 구분 추가 (parseServerErrorBody와 일관) - defaultFetcher: toMessage/makeParseError 헬퍼 추출로 반복 제거, 불필요 nested pipe 제거 - isErrorResponse 단위 테스트 19건 추가 (null, undefined, 원시값, 빈 문자열 등) - examples/: 삭제된 sendOne/sendOneFuture → send로 전환 (6개 파일) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../common/src/kakao/send/send_alimtalk.js | 6 +- .../javascript/common/src/rcs/send_rcs.js | 6 +- .../javascript/common/src/sms/send_lms.js | 6 +- .../javascript/common/src/sms/send_mms.js | 6 +- .../common/src/sms/send_overseas_sms.js | 8 +- .../javascript/common/src/sms/send_sms.js | 6 +- src/errors/defaultError.ts | 17 ++-- src/lib/defaultFetcher.ts | 93 +++++++------------ test/errors/defaultError.test.ts | 78 ++++++++++++++++ 9 files changed, 141 insertions(+), 85 deletions(-) create mode 100644 test/errors/defaultError.test.ts diff --git a/examples/javascript/common/src/kakao/send/send_alimtalk.js b/examples/javascript/common/src/kakao/send/send_alimtalk.js index 2e6e3dc5..7825f666 100644 --- a/examples/javascript/common/src/kakao/send/send_alimtalk.js +++ b/examples/javascript/common/src/kakao/send/send_alimtalk.js @@ -10,7 +10,7 @@ const messageService = new SolapiMessageService( // 단일 발송 예제 messageService - .sendOne({ + .send({ to: '수신번호', from: '계정에서 등록한 발신번호 입력', kakaoOptions: { @@ -32,7 +32,7 @@ messageService // 단일 예약 발송 예제 // 예약발송 시 현재 시간보다 과거의 시간을 입력할 경우 즉시 발송됩니다. messageService - .sendOneFuture( + .send( { to: '수신번호', from: '계정에서 등록한 발신번호 입력', @@ -50,7 +50,7 @@ messageService // disableSms: true, }, }, - '2022-12-08 00:00:00', + {scheduledDate: '2022-12-08 00:00:00'}, ) .then(res => console.log(res)); diff --git a/examples/javascript/common/src/rcs/send_rcs.js b/examples/javascript/common/src/rcs/send_rcs.js index 39cf7d09..742346dc 100644 --- a/examples/javascript/common/src/rcs/send_rcs.js +++ b/examples/javascript/common/src/rcs/send_rcs.js @@ -8,9 +8,9 @@ const messageService = new SolapiMessageService( 'ENTER_YOUR_API_SECRET', ); -// 단일 발송 예제, send 메소드로도 동일하게 사용가능 +// 단일 발송 예제 messageService - .sendOne({ + .send({ to: '수신번호', from: '계정에서 등록한 RCS용 발신번호 입력', text: '한글 45자, 영자 90자 이하 입력되면 자동으로 SMS타입의 메시지가 발송됩니다.', @@ -27,7 +27,7 @@ messageService }) .then(res => console.log(res)); -// 단일 예약발송 예제, send 메소드로도 동일하게 사용가능 +// 단일 예약발송 예제 messageService .send( { diff --git a/examples/javascript/common/src/sms/send_lms.js b/examples/javascript/common/src/sms/send_lms.js index 2961937d..479aa30b 100644 --- a/examples/javascript/common/src/sms/send_lms.js +++ b/examples/javascript/common/src/sms/send_lms.js @@ -10,7 +10,7 @@ const messageService = new SolapiMessageService( // 단일 발송 예제 messageService - .sendOne({ + .send({ to: '수신번호', from: '계정에서 등록한 발신번호 입력', text: '한글 45자, 영자 90자 이상 입력되면 자동으로 LMS타입의 문자메시지가 발송됩니다. 0123456789 ABCDEFGHIJKLMNOPQRSTUVWXYZ', @@ -21,14 +21,14 @@ messageService // 단일 예약 발송 예제 // 예약발송 시 현재 시간보다 과거의 시간을 입력할 경우 즉시 발송됩니다. messageService - .sendOneFuture( + .send( { to: '수신번호', from: '계정에서 등록한 발신번호 입력', text: '한글 45자, 영자 90자 이상 입력되면 자동으로 LMS타입의 문자메시지가 발송됩니다. 0123456789 ABCDEFGHIJKLMNOPQRSTUVWXYZ', subject: '문자 제목', // LMS, MMS 전용 옵션, SMS에서 해당 파라미터 추가될 경우 자동으로 LMS 변경처리 됨 }, - '2022-12-08 00:00:00', + {scheduledDate: '2022-12-08 00:00:00'}, ) .then(res => console.log(res)); diff --git a/examples/javascript/common/src/sms/send_mms.js b/examples/javascript/common/src/sms/send_mms.js index a7d1bb2c..b4fbc8f7 100644 --- a/examples/javascript/common/src/sms/send_mms.js +++ b/examples/javascript/common/src/sms/send_mms.js @@ -15,7 +15,7 @@ messageService .then(fileId => { // 단일 발송 예제 messageService - .sendOne({ + .send({ imageId: fileId, to: '수신번호', from: '계정에서 등록한 발신번호 입력', @@ -27,7 +27,7 @@ messageService // 단일 예약 발송 예제 // 예약발송 시 현재 시간보다 과거의 시간을 입력할 경우 즉시 발송됩니다. messageService - .sendOneFuture( + .send( { imageId: fileId, to: '수신번호', @@ -35,7 +35,7 @@ messageService text: 'imageId가 있으면 자동으로 MMS타입의 문자메시지가 발송됩니다. 0123456789 ABCDEFGHIJKLMNOPQRSTUVWXYZ', subject: '문자 제목', // LMS, MMS 전용 옵션, SMS에서 해당 파라미터 추가될 경우 자동으로 LMS 변경처리 됨 }, - '2022-12-08 00:00:00', + {scheduledDate: '2022-12-08 00:00:00'}, ) .then(res => console.log(res)); diff --git a/examples/javascript/common/src/sms/send_overseas_sms.js b/examples/javascript/common/src/sms/send_overseas_sms.js index 96f9b384..685816a6 100644 --- a/examples/javascript/common/src/sms/send_overseas_sms.js +++ b/examples/javascript/common/src/sms/send_overseas_sms.js @@ -8,9 +8,9 @@ const messageService = new SolapiMessageService( 'ENTER_YOUR_API_SECRET', ); -// 단일 발송 예제, send 메소드로도 동일하게 사용가능 +// 단일 발송 예제 messageService - .sendOne({ + .send({ to: '국제번호를 제외한 수신번호', from: '계정에서 등록한 발신번호 입력', text: '한글 45자, 영자 90자 이하 입력되면 자동으로 SMS타입의 메시지가 발송됩니다.', @@ -21,14 +21,14 @@ messageService // 단일 예약 발송 예제 // 예약발송 시 현재 시간보다 과거의 시간을 입력할 경우 즉시 발송됩니다. messageService - .sendOneFuture( + .send( { to: '국제번호를 제외한 수신번호', from: '계정에서 등록한 발신번호 입력', text: '한글 45자, 영자 90자 이하 입력되면 자동으로 SMS타입의 메시지가 발송됩니다.', country: '1', // 미국 국가번호, 국가번호 뒤에 추가로 번호가 붙는 국가들은 붙여서 기입해야 합니다. 예) 1 441 -> "1441" }, - '2022-12-08 00:00:00', + {scheduledDate: '2022-12-08 00:00:00'}, ) .then(res => console.log(res)); diff --git a/examples/javascript/common/src/sms/send_sms.js b/examples/javascript/common/src/sms/send_sms.js index 71b4fb28..5d814c94 100644 --- a/examples/javascript/common/src/sms/send_sms.js +++ b/examples/javascript/common/src/sms/send_sms.js @@ -17,16 +17,16 @@ messageService }) .then(res => console.log(res)); -// 단일 예약발송 예제, send 메소드로도 동일하게 사용가능 +// 단일 예약발송 예제 // 예약발송 시 현재 시간보다 과거의 시간을 입력할 경우 즉시 발송됩니다. messageService - .sendOneFuture( + .send( { to: '수신번호', from: '계정에서 등록한 발신번호 입력', text: '한글 45자, 영자 90자 이하 입력되면 자동으로 SMS타입의 메시지가 발송됩니다.', }, - '2022-12-08 00:00:00', + {scheduledDate: '2022-12-08 00:00:00'}, ) .then(res => console.log(res)); diff --git a/src/errors/defaultError.ts b/src/errors/defaultError.ts index cf47e4da..99de2656 100644 --- a/src/errors/defaultError.ts +++ b/src/errors/defaultError.ts @@ -152,10 +152,13 @@ Response: ${this.responseBody?.substring(0, 500) ?? '(empty)'}`; } } -export const isErrorResponse = (value: unknown): value is ErrorResponse => - value != null && - typeof value === 'object' && - 'errorCode' in value && - typeof (value as Record).errorCode === 'string' && - 'errorMessage' in value && - typeof (value as Record).errorMessage === 'string'; +export const isErrorResponse = (value: unknown): value is ErrorResponse => { + if (value == null || typeof value !== 'object') return false; + const obj = value as Record; + return ( + typeof obj.errorCode === 'string' && + obj.errorCode !== '' && + typeof obj.errorMessage === 'string' && + obj.errorMessage !== '' + ); +}; diff --git a/src/lib/defaultFetcher.ts b/src/lib/defaultFetcher.ts index f8a05730..26c5692a 100644 --- a/src/lib/defaultFetcher.ts +++ b/src/lib/defaultFetcher.ts @@ -20,19 +20,21 @@ class RetryableError extends Data.TaggedError('RetryableError')<{ readonly error?: unknown; }> {} +const toMessage = (e: unknown): string => + e instanceof Error ? e.message : String(e); + +const makeParseError = (res: Response, message: string) => + new DefaultError({ + errorCode: 'ParseError', + errorMessage: message, + context: {responseStatus: res.status, responseUrl: res.url}, + }); + const handleOkResponse = (res: Response) => pipe( Effect.tryPromise({ try: () => res.text(), - catch: e => - new DefaultError({ - errorCode: 'ParseError', - errorMessage: e instanceof Error ? e.message : String(e), - context: { - responseStatus: res.status, - responseUrl: res.url, - }, - }), + catch: e => makeParseError(res, toMessage(e)), }), Effect.flatMap(responseText => { if (!responseText) { @@ -40,27 +42,12 @@ const handleOkResponse = (res: Response) => return Effect.succeed({} as R); } return Effect.fail( - new DefaultError({ - errorCode: 'ParseError', - errorMessage: 'API returned empty response body', - context: { - responseStatus: res.status, - responseUrl: res.url, - }, - }), + makeParseError(res, 'API returned empty response body'), ); } return Effect.try({ try: () => JSON.parse(responseText) as R, - catch: e => - new DefaultError({ - errorCode: 'ParseError', - errorMessage: e instanceof Error ? e.message : String(e), - context: { - responseStatus: res.status, - responseUrl: res.url, - }, - }), + catch: e => makeParseError(res, toMessage(e)), }); }), ); @@ -69,15 +56,7 @@ const handleClientErrorResponse = (res: Response) => pipe( Effect.tryPromise({ try: () => res.text(), - catch: e => - new DefaultError({ - errorCode: 'ParseError', - errorMessage: e instanceof Error ? e.message : String(e), - context: { - responseStatus: res.status, - responseUrl: res.url, - }, - }), + catch: e => makeParseError(res, toMessage(e)), }), Effect.flatMap(text => { const genericError = new ClientError({ @@ -87,12 +66,20 @@ const handleClientErrorResponse = (res: Response) => url: res.url, }); - return pipe( + return Effect.flatMap( Effect.try({ try: () => JSON.parse(text) as unknown, - catch: () => genericError, + catch: (e: unknown) => + e instanceof SyntaxError + ? genericError + : new ClientError({ + errorCode: 'ResponseParseError', + errorMessage: toMessage(e), + httpStatus: res.status, + url: res.url, + }), }), - Effect.flatMap(json => + json => Effect.fail( isErrorResponse(json) ? new ClientError({ @@ -103,7 +90,6 @@ const handleClientErrorResponse = (res: Response) => }) : genericError, ), - ), ); }), ); @@ -117,26 +103,20 @@ function parseServerErrorBody( genericError: ServerError, makeError: (errorCode: string, errorMessage: string) => ServerError, ): Effect.Effect { - return pipe( + return Effect.flatMap( Effect.try({ try: () => JSON.parse(text) as unknown, - catch: (parseError: unknown) => - parseError instanceof SyntaxError + catch: (e: unknown) => + e instanceof SyntaxError ? genericError - : makeError( - 'ResponseParseError', - parseError instanceof Error - ? parseError.message - : String(parseError), - ), + : makeError('ResponseParseError', toMessage(e)), }), - Effect.flatMap(json => + json => Effect.fail( isErrorResponse(json) ? makeError(json.errorCode, json.errorMessage) : genericError, ), - ), ); } @@ -147,11 +127,8 @@ const handleServerErrorResponse = (res: Response) => catch: e => new DefaultError({ errorCode: 'ResponseReadError', - errorMessage: e instanceof Error ? e.message : String(e), - context: { - responseStatus: res.status, - responseUrl: res.url, - }, + errorMessage: toMessage(e), + context: {responseStatus: res.status, responseUrl: res.url}, }), }), Effect.flatMap(text => { @@ -196,10 +173,8 @@ export function defaultFetcherEffect( catch: e => new DefaultError({ errorCode: 'JSONStringifyError', - errorMessage: e instanceof Error ? e.message : String(e), - context: { - data, - }, + errorMessage: toMessage(e), + context: {data}, }), }); diff --git a/test/errors/defaultError.test.ts b/test/errors/defaultError.test.ts new file mode 100644 index 00000000..53eb00e3 --- /dev/null +++ b/test/errors/defaultError.test.ts @@ -0,0 +1,78 @@ +import {describe, expect, it} from 'vitest'; +import {isErrorResponse} from '@/errors/defaultError'; + +describe('isErrorResponse', () => { + it('should return true for valid ErrorResponse', () => { + expect( + isErrorResponse({errorCode: 'BadRequest', errorMessage: 'Invalid param'}), + ).toBe(true); + }); + + it('should return true with extra fields', () => { + expect( + isErrorResponse({ + errorCode: 'NotFound', + errorMessage: 'Not found', + extra: 123, + }), + ).toBe(true); + }); + + it('should return false for null', () => { + expect(isErrorResponse(null)).toBe(false); + }); + + it('should return false for undefined', () => { + expect(isErrorResponse(undefined)).toBe(false); + }); + + it.each([ + 0, + 1, + '', + 'string', + true, + false, + ])('should return false for primitive: %s', value => { + expect(isErrorResponse(value)).toBe(false); + }); + + it('should return false for array', () => { + expect(isErrorResponse([])).toBe(false); + expect(isErrorResponse(['errorCode', 'errorMessage'])).toBe(false); + }); + + it('should return false when errorCode is missing', () => { + expect(isErrorResponse({errorMessage: 'msg'})).toBe(false); + }); + + it('should return false when errorMessage is missing', () => { + expect(isErrorResponse({errorCode: 'code'})).toBe(false); + }); + + it('should return false when both fields are missing', () => { + expect(isErrorResponse({})).toBe(false); + }); + + it('should return false when errorCode is not a string', () => { + expect(isErrorResponse({errorCode: 123, errorMessage: 'msg'})).toBe(false); + }); + + it('should return false when errorMessage is not a string', () => { + expect(isErrorResponse({errorCode: 'code', errorMessage: null})).toBe( + false, + ); + }); + + it('should reject empty errorCode string', () => { + expect(isErrorResponse({errorCode: '', errorMessage: 'msg'})).toBe(false); + }); + + it('should reject empty errorMessage string', () => { + expect(isErrorResponse({errorCode: 'code', errorMessage: ''})).toBe(false); + }); + + it('should reject both empty strings', () => { + expect(isErrorResponse({errorCode: '', errorMessage: ''})).toBe(false); + }); +}); From ac1206de52f5888bdd9a4f468474f1d9621b3c48 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Tue, 14 Apr 2026 15:36:07 +0900 Subject: [PATCH 11/24] =?UTF-8?q?refactor:=20sendOne=20=EC=A0=84=EC=9A=A9?= =?UTF-8?q?=20=EC=8A=A4=ED=82=A4=EB=A7=88/=ED=83=80=EC=9E=85=20=EB=B0=8F?= =?UTF-8?q?=20barrel=20export=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - singleMessageSendingRequestSchema, SingleMessageSendingRequestSchema 제거 (sendOne 전용) - singleMessageSentResponseSchema, SingleMessageSentResponse 제거 (sendOne 전용) - barrel export (requests/index.ts, responses/index.ts)에서 해당 항목 제거 - 미사용 messageTypeSchema import 정리 (messageResponses.ts) - sendMessage.test.ts에서 삭제된 스키마 테스트 3건 제거 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/models/requests/index.ts | 2 - src/models/requests/messages/sendMessage.ts | 9 --- src/models/responses/index.ts | 2 - src/models/responses/messageResponses.ts | 17 +----- .../requests/messages/sendMessage.test.ts | 59 ------------------- 5 files changed, 1 insertion(+), 88 deletions(-) diff --git a/src/models/requests/index.ts b/src/models/requests/index.ts index b47a5cb4..36436d27 100644 --- a/src/models/requests/index.ts +++ b/src/models/requests/index.ts @@ -107,8 +107,6 @@ export { type RequestSendOneMessageSchema, requestSendMessageSchema, requestSendOneMessageSchema, - type SingleMessageSendingRequestSchema, - singleMessageSendingRequestSchema, } from './messages/sendMessage'; // Voice export { diff --git a/src/models/requests/messages/sendMessage.ts b/src/models/requests/messages/sendMessage.ts index b53f2815..1ed72e28 100644 --- a/src/models/requests/messages/sendMessage.ts +++ b/src/models/requests/messages/sendMessage.ts @@ -93,11 +93,6 @@ const agentWithDefaultSchema = Schema.optional(defaultAgentTypeSchema).pipe( Schema.withConstructorDefault(() => defaultAgentValue), ); -export const singleMessageSendingRequestSchema = Schema.Struct({ - message: requestSendOneMessageSchema, - agent: agentWithDefaultSchema, -}); - export const multipleMessageSendingRequestSchema = Schema.Struct({ allowDuplicates: Schema.optional(Schema.Boolean), agent: agentWithDefaultSchema, @@ -111,7 +106,3 @@ export const multipleMessageSendingRequestSchema = Schema.Struct({ export type MultipleMessageSendingRequestSchema = Schema.Schema.Type< typeof multipleMessageSendingRequestSchema >; - -export type SingleMessageSendingRequestSchema = Schema.Schema.Type< - typeof singleMessageSendingRequestSchema ->; diff --git a/src/models/responses/index.ts b/src/models/responses/index.ts index be677b96..630d6dcc 100644 --- a/src/models/responses/index.ts +++ b/src/models/responses/index.ts @@ -52,8 +52,6 @@ export { type RequestKakaoChannelTokenResponse, removeGroupMessagesResponseSchema, requestKakaoChannelTokenResponseSchema, - type SingleMessageSentResponse, - singleMessageSentResponseSchema, } from './messageResponses'; // Send Detail Response export { diff --git a/src/models/responses/messageResponses.ts b/src/models/responses/messageResponses.ts index 9fb0adfc..5807b151 100644 --- a/src/models/responses/messageResponses.ts +++ b/src/models/responses/messageResponses.ts @@ -9,22 +9,7 @@ import { messageTypeRecordSchema, } from '@internal-types/commonTypes'; import {Schema} from 'effect'; -import {messageSchema, messageTypeSchema} from '../base/messages/message'; - -export const singleMessageSentResponseSchema = Schema.Struct({ - groupId: Schema.String, - to: Schema.String, - from: Schema.String, - type: messageTypeSchema, - statusMessage: Schema.String, - country: Schema.String, - messageId: Schema.String, - statusCode: Schema.String, - accountId: Schema.String, -}); -export type SingleMessageSentResponse = Schema.Schema.Type< - typeof singleMessageSentResponseSchema ->; +import {messageSchema} from '../base/messages/message'; export const groupMessageResponseSchema = Schema.Struct({ count: countSchema, diff --git a/test/models/requests/messages/sendMessage.test.ts b/test/models/requests/messages/sendMessage.test.ts index c06263f6..6ccfe434 100644 --- a/test/models/requests/messages/sendMessage.test.ts +++ b/test/models/requests/messages/sendMessage.test.ts @@ -5,7 +5,6 @@ import { phoneNumberSchema, requestSendMessageSchema, requestSendOneMessageSchema, - singleMessageSendingRequestSchema, } from '@/models/requests/messages/sendMessage'; describe('phoneNumberSchema', () => { @@ -231,64 +230,6 @@ describe('requestSendMessageSchema', () => { }); }); -describe('singleMessageSendingRequestSchema', () => { - it('should validate single message sending request with default agent', () => { - const requestData = { - message: { - to: '010-1234-5678', - from: '010-9876-5432', - text: 'Hello, world!', - }, - }; - - const result = Schema.decodeUnknownSync(singleMessageSendingRequestSchema)( - requestData, - ); - - expect(result.message.to).toBe('01012345678'); - expect(result.message.from).toBe('01098765432'); - expect(result.message.text).toBe('Hello, world!'); - expect(result.agent).toBeDefined(); - expect(result.agent.sdkVersion).toBeDefined(); - expect(result.agent.osPlatform).toBeDefined(); - }); - - it('should validate single message sending request with custom agent', () => { - const requestData = { - message: { - to: '010-1234-5678', - text: 'Hello, world!', - }, - agent: { - sdkVersion: 'custom/1.0.0', - osPlatform: 'custom platform', - appId: 'my-app-id', - }, - }; - - const result = Schema.decodeUnknownSync(singleMessageSendingRequestSchema)( - requestData, - ); - - expect(result.agent.sdkVersion).toBe('custom/1.0.0'); - expect(result.agent.osPlatform).toBe('custom platform'); - expect(result.agent.appId).toBe('my-app-id'); - }); - - it('should fail when message field is missing', () => { - const requestData = { - agent: { - sdkVersion: 'custom/1.0.0', - osPlatform: 'custom platform', - }, - }; - - expect(() => { - Schema.decodeUnknownSync(singleMessageSendingRequestSchema)(requestData); - }).toThrow(); - }); -}); - describe('multipleMessageSendingRequestSchema', () => { it('should validate multiple message sending request with default values', () => { const requestData = { From 556c729b25e602311ecf7f2204c042246c102167 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Tue, 14 Apr 2026 17:13:20 +0900 Subject: [PATCH 12/24] =?UTF-8?q?refactor:=20dead=20code=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0,=20deprecated=20alias=20=EC=A0=95=EB=A6=AC,=20bindSer?= =?UTF-8?q?vices=20=EB=AA=85=EC=8B=9C=EC=A0=81=20=EB=B0=94=EC=9D=B8?= =?UTF-8?q?=EB=94=A9=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 미사용 default export 제거 (fileToBase64, defaultFetcher) - 빈 섹션 주석 제거 (models/index.ts, responses/index.ts) - effectErrorHandler 타입 가드 개선: 필드별 as 어설션 → Record 통합 - bmsCommerce NumberOrNumericString 타입 어설션 Why 주석 보강 - deprecated v5 alias 7개 제거 (KakaoAlimtalkTemplateInterface 등) - bindServices 동적 프로토타입 순회 → 32개 메서드 명시적 .bind() 전환 - Writable 헬퍼 타입 및 DefaultService import 제거 - 타입 어설션 7개 제거 (as unknown as, as never, as Record 등) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/index.ts | 127 +++++++++++------- src/lib/defaultFetcher.ts | 18 --- src/lib/effectErrorHandler.ts | 21 +-- src/lib/fileToBase64.ts | 12 -- src/models/base/kakao/bms/bmsCarousel.ts | 10 -- src/models/base/kakao/bms/bmsCommerce.ts | 7 +- src/models/base/kakao/bms/bmsWideItem.ts | 9 -- src/models/base/kakao/bms/index.ts | 5 - .../base/kakao/kakaoAlimtalkTemplate.ts | 5 - src/models/base/kakao/kakaoChannel.ts | 5 - src/models/index.ts | 4 - src/models/responses/index.ts | 2 - 12 files changed, 95 insertions(+), 130 deletions(-) diff --git a/src/index.ts b/src/index.ts index 73289936..dddecd41 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,4 @@ import CashService from '@services/cash/cashService'; -import DefaultService from '@services/defaultService'; import IamService from '@services/iam/iamService'; import KakaoChannelService from '@services/kakao/channels/kakaoChannelService'; import KakaoTemplateService from '@services/kakao/templates/kakaoTemplateService'; @@ -8,8 +7,6 @@ import MessageService from '@services/messages/messageService'; import StorageService from '@services/storage/storageService'; import {ApiKeyError} from './errors/defaultError'; -type Writable = {-readonly [P in keyof T]: T[P]}; - // Errors export * from './errors/defaultError'; // Models (base types, request types, response types, schemas) @@ -25,14 +22,6 @@ export * from './types/index'; * @see https://developers.solapi.com/category/nodejs */ export class SolapiMessageService { - private readonly cashService: CashService; - private readonly iamService: IamService; - private readonly kakaoChannelService: KakaoChannelService; - private readonly kakaoTemplateService: KakaoTemplateService; - private readonly groupService: GroupService; - private readonly messageService: MessageService; - private readonly storageService: StorageService; - // CashService 위임 /** * 잔액조회 @@ -259,43 +248,87 @@ export class SolapiMessageService { }); } - this.cashService = new CashService(apiKey, apiSecret); - this.iamService = new IamService(apiKey, apiSecret); - this.kakaoChannelService = new KakaoChannelService(apiKey, apiSecret); - this.kakaoTemplateService = new KakaoTemplateService(apiKey, apiSecret); - this.groupService = new GroupService(apiKey, apiSecret); - this.messageService = new MessageService(apiKey, apiSecret); - this.storageService = new StorageService(apiKey, apiSecret); - - this.bindServices([ - this.cashService, - this.iamService, - this.kakaoChannelService, - this.kakaoTemplateService, - this.groupService, - this.messageService, - this.storageService, - ]); - } - - private bindServices(services: DefaultService[]) { - for (const service of services) { - const proto = Object.getPrototypeOf(service); - const methodNames = Object.getOwnPropertyNames(proto).filter( - name => - name !== 'constructor' && - typeof (proto as Record)[name] === 'function', + const cashService = new CashService(apiKey, apiSecret); + const iamService = new IamService(apiKey, apiSecret); + const kakaoChannelService = new KakaoChannelService(apiKey, apiSecret); + const kakaoTemplateService = new KakaoTemplateService(apiKey, apiSecret); + const groupService = new GroupService(apiKey, apiSecret); + const messageService = new MessageService(apiKey, apiSecret); + const storageService = new StorageService(apiKey, apiSecret); + + // CashService + this.getBalance = cashService.getBalance.bind(cashService); + + // IamService + this.getBlacks = iamService.getBlacks.bind(iamService); + this.getBlockGroups = iamService.getBlockGroups.bind(iamService); + this.getBlockNumbers = iamService.getBlockNumbers.bind(iamService); + + // KakaoChannelService + this.getKakaoChannelCategories = + kakaoChannelService.getKakaoChannelCategories.bind(kakaoChannelService); + this.getKakaoChannels = + kakaoChannelService.getKakaoChannels.bind(kakaoChannelService); + this.getKakaoChannel = + kakaoChannelService.getKakaoChannel.bind(kakaoChannelService); + this.requestKakaoChannelToken = + kakaoChannelService.requestKakaoChannelToken.bind(kakaoChannelService); + this.createKakaoChannel = + kakaoChannelService.createKakaoChannel.bind(kakaoChannelService); + this.removeKakaoChannel = + kakaoChannelService.removeKakaoChannel.bind(kakaoChannelService); + + // KakaoTemplateService + this.getKakaoAlimtalkTemplateCategories = + kakaoTemplateService.getKakaoAlimtalkTemplateCategories.bind( + kakaoTemplateService, + ); + this.createKakaoAlimtalkTemplate = + kakaoTemplateService.createKakaoAlimtalkTemplate.bind( + kakaoTemplateService, + ); + this.getKakaoAlimtalkTemplates = + kakaoTemplateService.getKakaoAlimtalkTemplates.bind(kakaoTemplateService); + this.getKakaoAlimtalkTemplate = + kakaoTemplateService.getKakaoAlimtalkTemplate.bind(kakaoTemplateService); + this.cancelInspectionKakaoAlimtalkTemplate = + kakaoTemplateService.cancelInspectionKakaoAlimtalkTemplate.bind( + kakaoTemplateService, + ); + this.updateKakaoAlimtalkTemplate = + kakaoTemplateService.updateKakaoAlimtalkTemplate.bind( + kakaoTemplateService, + ); + this.updateKakaoAlimtalkTemplateName = + kakaoTemplateService.updateKakaoAlimtalkTemplateName.bind( + kakaoTemplateService, + ); + this.removeKakaoAlimtalkTemplate = + kakaoTemplateService.removeKakaoAlimtalkTemplate.bind( + kakaoTemplateService, ); - for (const name of methodNames) { - const key = name as keyof SolapiMessageService; - const method = ( - service as unknown as Record unknown> - )[name]; - (this as Writable)[key] = method.bind( - service, - ) as never; - } - } + // GroupService + this.createGroup = groupService.createGroup.bind(groupService); + this.addMessagesToGroup = + groupService.addMessagesToGroup.bind(groupService); + this.sendGroup = groupService.sendGroup.bind(groupService); + this.reserveGroup = groupService.reserveGroup.bind(groupService); + this.removeReservationToGroup = + groupService.removeReservationToGroup.bind(groupService); + this.getGroups = groupService.getGroups.bind(groupService); + this.getGroup = groupService.getGroup.bind(groupService); + this.getGroupMessages = groupService.getGroupMessages.bind(groupService); + this.removeGroupMessages = + groupService.removeGroupMessages.bind(groupService); + this.removeGroup = groupService.removeGroup.bind(groupService); + + // MessageService + this.send = messageService.send.bind(messageService); + this.getMessages = messageService.getMessages.bind(messageService); + this.getStatistics = messageService.getStatistics.bind(messageService); + + // StorageService + this.uploadFile = storageService.uploadFile.bind(storageService); } } diff --git a/src/lib/defaultFetcher.ts b/src/lib/defaultFetcher.ts index 26c5692a..9493f5f2 100644 --- a/src/lib/defaultFetcher.ts +++ b/src/lib/defaultFetcher.ts @@ -8,7 +8,6 @@ import { ServerError, } from '../errors/defaultError'; import getAuthInfo, {AuthenticationParameter} from './authenticator'; -import {runSafePromise} from './effectErrorHandler'; type DefaultRequest = { url: string; @@ -262,20 +261,3 @@ export function defaultFetcherEffect( ), ); } - -/** - * 공용 API 클라이언트 함수 (Promise 반환) - * @throws DefaultError 발송 실패 등 API 상의 다양한 오류를 표시합니다. - * @param authParameter API 인증을 위한 파라미터 - * @param request API URI, HTTP method 정의 - * @param data API에 요청할 request body 데이터 - */ -export default async function defaultFetcher( - authParameter: AuthenticationParameter, - request: DefaultRequest, - data?: T, -): Promise { - return runSafePromise( - defaultFetcherEffect(authParameter, request, data), - ); -} diff --git a/src/lib/effectErrorHandler.ts b/src/lib/effectErrorHandler.ts index 1cabda78..96f4f3f9 100644 --- a/src/lib/effectErrorHandler.ts +++ b/src/lib/effectErrorHandler.ts @@ -10,17 +10,18 @@ import { const extractDefectInfo = ( defect: unknown, ): {summary: string; details: string} => { - if (defect && typeof defect === 'object' && '_tag' in defect) { - const tag = (defect as {_tag: string})._tag; - const message = - 'message' in defect ? String((defect as {message: unknown}).message) : ''; - return { - summary: `${tag}${message ? `: ${message}` : ''}`, - details: `Tagged Error [${tag}]: ${JSON.stringify(defect, null, 2)}`, - }; - } - if (defect !== null && typeof defect === 'object') { + const obj = defect as Record; + + if ('_tag' in defect && typeof obj._tag === 'string') { + const tag = obj._tag; + const message = 'message' in defect ? String(obj.message) : ''; + return { + summary: `${tag}${message ? `: ${message}` : ''}`, + details: `Tagged Error [${tag}]: ${JSON.stringify(defect, null, 2)}`, + }; + } + const keys = Object.keys(defect); const summary = keys.length > 0 diff --git a/src/lib/fileToBase64.ts b/src/lib/fileToBase64.ts index 58832b7e..7582f8e2 100644 --- a/src/lib/fileToBase64.ts +++ b/src/lib/fileToBase64.ts @@ -2,7 +2,6 @@ import {promises as fs} from 'node:fs'; import {URL} from 'node:url'; import * as Effect from 'effect/Effect'; import {DefaultError} from '../errors/defaultError'; -import {runSafePromise} from './effectErrorHandler'; // 내부 유틸: 주어진 문자열이 http(s) 스킴의 URL 인지 판별 const isHttpUrl = (value: string): boolean => { @@ -71,14 +70,3 @@ export function fileToBase64Effect( ): Effect.Effect { return isHttpUrl(path) ? fromUrl(path) : fromPath(path); } - -/** - * 주어진 경로(URL 또는 로컬 경로)의 파일을 Base64 문자열로 변환합니다. - * – http(s) URL 인 경우 네트워크로 가져오고, 그 외는 로컬 파일로 처리합니다. - * – 오류는 명확하게 구분하여 반환합니다. - * @param path 파일의 로컬 경로 또는 접근 가능한 URL - * @returns Base64 문자열 - */ -export default async function fileToBase64(path: string): Promise { - return runSafePromise(fileToBase64Effect(path)); -} diff --git a/src/models/base/kakao/bms/bmsCarousel.ts b/src/models/base/kakao/bms/bmsCarousel.ts index 15d4ae1d..b03f3056 100644 --- a/src/models/base/kakao/bms/bmsCarousel.ts +++ b/src/models/base/kakao/bms/bmsCarousel.ts @@ -137,13 +137,3 @@ export const bmsCarouselCommerceSchema = Schema.Struct({ export type BmsCarouselCommerceSchema = Schema.Schema.Type< typeof bmsCarouselCommerceSchema >; - -/** - * @deprecated bmsCarouselHeadSchema 사용 권장 - */ -export const bmsCarouselCommerceHeadSchema = bmsCarouselHeadSchema; - -/** - * @deprecated bmsCarouselTailSchema 사용 권장 - */ -export const bmsCarouselCommerceTailSchema = bmsCarouselTailSchema; diff --git a/src/models/base/kakao/bms/bmsCommerce.ts b/src/models/base/kakao/bms/bmsCommerce.ts index c4e1dd22..6e2f1d33 100644 --- a/src/models/base/kakao/bms/bmsCommerce.ts +++ b/src/models/base/kakao/bms/bmsCommerce.ts @@ -17,10 +17,11 @@ export type BmsCommerce = { * - string 타입: parseFloat로 변환, 유효하지 않으면 검증 실패 * * API 호환성: 기존 number 입력 및 string 입력 모두 허용 - * 출력 타입: number + * 출력 타입: number, 입력 타입: number | string * - * Note: 타입 어설션을 사용하여 Encoded 타입을 number로 강제합니다. - * 이는 기존 API 타입 호환성을 유지하면서 런타임에서 문자열 입력도 허용하기 위함입니다. + * Why: Encoded 타입을 number로 강제하여 공개 API 타입 호환성 유지. + * transformOrFail의 추론 Encoded 타입은 number | string이지만, + * downstream 스키마 체인(kakaoOption → sendMessage)에서 number를 기대함. */ const NumberOrNumericString: Schema.Schema = Schema.transformOrFail( diff --git a/src/models/base/kakao/bms/bmsWideItem.ts b/src/models/base/kakao/bms/bmsWideItem.ts index dfe77222..33a38960 100644 --- a/src/models/base/kakao/bms/bmsWideItem.ts +++ b/src/models/base/kakao/bms/bmsWideItem.ts @@ -63,12 +63,3 @@ export const bmsSubWideItemSchema = Schema.Struct({ export type BmsSubWideItemSchema = Schema.Schema.Type< typeof bmsSubWideItemSchema >; - -/** - * @deprecated bmsMainWideItemSchema 또는 bmsSubWideItemSchema 사용 권장 - * BMS 와이드 아이템 통합 스키마 (하위 호환성) - */ -export const bmsWideItemSchema = bmsSubWideItemSchema; - -export type BmsWideItem = BmsSubWideItem; -export type BmsWideItemSchema = BmsSubWideItemSchema; diff --git a/src/models/base/kakao/bms/index.ts b/src/models/base/kakao/bms/index.ts index 26cf8810..f3b33aaa 100644 --- a/src/models/base/kakao/bms/index.ts +++ b/src/models/base/kakao/bms/index.ts @@ -32,10 +32,8 @@ export { type BmsCarouselFeedSchema, type BmsCarouselHeadSchema, type BmsCarouselTailSchema, - bmsCarouselCommerceHeadSchema, bmsCarouselCommerceItemSchema, bmsCarouselCommerceSchema, - bmsCarouselCommerceTailSchema, bmsCarouselFeedItemSchema, bmsCarouselFeedSchema, bmsCarouselHeadSchema, @@ -64,9 +62,6 @@ export { type BmsMainWideItemSchema, type BmsSubWideItem, type BmsSubWideItemSchema, - type BmsWideItem, - type BmsWideItemSchema, bmsMainWideItemSchema, bmsSubWideItemSchema, - bmsWideItemSchema, } from './bmsWideItem'; diff --git a/src/models/base/kakao/kakaoAlimtalkTemplate.ts b/src/models/base/kakao/kakaoAlimtalkTemplate.ts index ed989a05..b279846b 100644 --- a/src/models/base/kakao/kakaoAlimtalkTemplate.ts +++ b/src/models/base/kakao/kakaoAlimtalkTemplate.ts @@ -134,11 +134,6 @@ export type KakaoAlimtalkTemplateSchema = Schema.Schema.Type< typeof kakaoAlimtalkTemplateSchema >; -/** - * @deprecated v6.0.0에서 KakaoAlimtalkTemplateSchema를 사용하세요 - */ -export type KakaoAlimtalkTemplateInterface = KakaoAlimtalkTemplateSchema; - /** * 날짜가 Date로 변환된 알림톡 템플릿 타입 */ diff --git a/src/models/base/kakao/kakaoChannel.ts b/src/models/base/kakao/kakaoChannel.ts index 53a6c28c..b9886654 100644 --- a/src/models/base/kakao/kakaoChannel.ts +++ b/src/models/base/kakao/kakaoChannel.ts @@ -33,11 +33,6 @@ export const kakaoChannelSchema = Schema.Struct({ export type KakaoChannelSchema = Schema.Schema.Type; -/** - * @deprecated v6.0.0에서 KakaoChannelSchema를 사용하세요 - */ -export type KakaoChannelInterface = KakaoChannelSchema; - /** * 날짜 필드가 Date로 변환된 카카오 채널 타입 */ diff --git a/src/models/index.ts b/src/models/index.ts index 9cbff4db..282abea5 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -1,5 +1,3 @@ -// Base Models - Messages - // Base Models - Kakao BMS export * from './base/kakao/bms'; @@ -12,7 +10,6 @@ export { type KakaoAlimtalkTemplateCommentType, type KakaoAlimtalkTemplateEmphasizeType, type KakaoAlimtalkTemplateHighlightType, - type KakaoAlimtalkTemplateInterface, type KakaoAlimtalkTemplateItemType, type KakaoAlimtalkTemplateMessageType, type KakaoAlimtalkTemplateSchema, @@ -50,7 +47,6 @@ export { decodeKakaoChannel, type KakaoChannel, type KakaoChannelCategory, - type KakaoChannelInterface, type KakaoChannelSchema, kakaoChannelCategorySchema, kakaoChannelSchema, diff --git a/src/models/responses/index.ts b/src/models/responses/index.ts index 630d6dcc..3bb1de1c 100644 --- a/src/models/responses/index.ts +++ b/src/models/responses/index.ts @@ -1,5 +1,3 @@ -// Message Responses - // IAM Responses export { type GetBlacksResponse, From 233bb6b1984b9f8f0f16551148b6015e2a8d1724 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Tue, 14 Apr 2026 17:32:25 +0900 Subject: [PATCH 13/24] =?UTF-8?q?fix:=20=EB=A6=AC=EB=B7=B0=20=ED=94=BC?= =?UTF-8?q?=EB=93=9C=EB=B0=B1=20=EB=B0=98=EC=98=81=20=E2=80=94=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8,=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CLAUDE.md/AGENTS.md: bindServices() 참조를 명시적 .bind()로 업데이트 - effectErrorHandler 테스트: non-string _tag, message 없는 tagged defect 케이스 추가 - solapiMessageService 테스트: 32개 메서드 전체 바인딩 검증 테스트 추가 Co-Authored-By: Claude Opus 4.6 (1M context) --- AGENTS.md | 2 +- CLAUDE.md | 2 +- test/lib/effectErrorHandler.test.ts | 22 +++++++++++++++ test/solapiMessageService.test.ts | 44 +++++++++++++++++++++++++++++ 4 files changed, 68 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f5572936..d2c7c410 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -141,7 +141,7 @@ Schema.String.pipe( ## Architecture Notes -**Service Facade**: `SolapiMessageService`가 7개 도메인 서비스를 `bindServices()`로 동적 바인딩. +**Service Facade**: `SolapiMessageService`가 7개 도메인 서비스를 명시적 `.bind()`로 위임. **Error Flow**: ``` diff --git a/CLAUDE.md b/CLAUDE.md index 72d1eeda..bbc34bbb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ pnpm docs # Generate TypeDoc documentation ## Architecture ### Entry Point & Service Facade -`SolapiMessageService` (src/index.ts)가 모든 도메인 서비스를 `bindServices()`로 위임. +`SolapiMessageService` (src/index.ts)가 모든 도메인 서비스 메서드를 명시적 `.bind()`로 위임. ### Service Layer 모든 서비스는 `DefaultService` (src/services/defaultService.ts) 상속: diff --git a/test/lib/effectErrorHandler.test.ts b/test/lib/effectErrorHandler.test.ts index e36cf8ff..01487843 100644 --- a/test/lib/effectErrorHandler.test.ts +++ b/test/lib/effectErrorHandler.test.ts @@ -33,6 +33,28 @@ describe('runSafeSync', () => { } }); + it('should handle defect with non-string _tag as generic object', () => { + const effect = Effect.die({_tag: 42, message: 'numeric tag'}); + try { + runSafeSync(effect); + } catch (e) { + const err = e as UnexpectedDefectError; + expect(err._tag).toBe('UnexpectedDefectError'); + expect(err.message).not.toContain('Tagged Error'); + } + }); + + it('should handle tagged defect without message property', () => { + const effect = Effect.die({_tag: 'CustomTag'}); + try { + runSafeSync(effect); + } catch (e) { + const err = e as UnexpectedDefectError; + expect(err._tag).toBe('UnexpectedDefectError'); + expect(err.message).toContain('CustomTag'); + } + }); + it('should throw original Error for Error defects', () => { const originalError = new TypeError('type mismatch'); const effect = Effect.die(originalError); diff --git a/test/solapiMessageService.test.ts b/test/solapiMessageService.test.ts index 082ee5a2..0dd0dd32 100644 --- a/test/solapiMessageService.test.ts +++ b/test/solapiMessageService.test.ts @@ -31,4 +31,48 @@ describe('SolapiMessageService constructor', () => { expect(service).toBeInstanceOf(SolapiMessageService); expect(service.send).toBeTypeOf('function'); }); + + it('should bind all 32 service methods as functions', () => { + const service = new SolapiMessageService( + 'validApiKey1234', + 'validSecret1234', + ); + const expectedMethods = [ + 'getBalance', + 'getBlacks', + 'getBlockGroups', + 'getBlockNumbers', + 'getKakaoChannelCategories', + 'getKakaoChannels', + 'getKakaoChannel', + 'requestKakaoChannelToken', + 'createKakaoChannel', + 'removeKakaoChannel', + 'getKakaoAlimtalkTemplateCategories', + 'createKakaoAlimtalkTemplate', + 'getKakaoAlimtalkTemplates', + 'getKakaoAlimtalkTemplate', + 'cancelInspectionKakaoAlimtalkTemplate', + 'updateKakaoAlimtalkTemplate', + 'updateKakaoAlimtalkTemplateName', + 'removeKakaoAlimtalkTemplate', + 'createGroup', + 'addMessagesToGroup', + 'sendGroup', + 'reserveGroup', + 'removeReservationToGroup', + 'getGroups', + 'getGroup', + 'getGroupMessages', + 'removeGroupMessages', + 'removeGroup', + 'send', + 'getMessages', + 'getStatistics', + 'uploadFile', + ] as const; + for (const method of expectedMethods) { + expect(service[method]).toBeTypeOf('function'); + } + }); }); From 1f3fc8aa45a722998c8b4d8de9dc08ce9042b624 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Tue, 14 Apr 2026 17:35:27 +0900 Subject: [PATCH 14/24] =?UTF-8?q?fix:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=97=90=20expect.assertions()=20=EC=B6=94=EA=B0=80=EB=A1=9C?= =?UTF-8?q?=20false-green=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- test/lib/effectErrorHandler.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/lib/effectErrorHandler.test.ts b/test/lib/effectErrorHandler.test.ts index 01487843..bc280e5f 100644 --- a/test/lib/effectErrorHandler.test.ts +++ b/test/lib/effectErrorHandler.test.ts @@ -34,6 +34,7 @@ describe('runSafeSync', () => { }); it('should handle defect with non-string _tag as generic object', () => { + expect.assertions(2); const effect = Effect.die({_tag: 42, message: 'numeric tag'}); try { runSafeSync(effect); @@ -45,6 +46,7 @@ describe('runSafeSync', () => { }); it('should handle tagged defect without message property', () => { + expect.assertions(2); const effect = Effect.die({_tag: 'CustomTag'}); try { runSafeSync(effect); From 5e0cb33e461f49d75b01f0f3eeea19f3d1d66a43 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Tue, 14 Apr 2026 18:21:39 +0900 Subject: [PATCH 15/24] =?UTF-8?q?refactor:=20Effect=20Language=20Service?= =?UTF-8?q?=20=EB=8F=84=EC=9E=85=20=EB=B0=8F=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EC=95=88=EC=A0=84=EC=84=B1=C2=B7DRY=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @effect/language-service 설치 및 tsconfig.json plugin 설정 - as 캐스팅 제거: isTaggedDefect 타입 가드 도입, isErrorResponse in 연산자 narrowing - DRY: DefaultService에 getWithQuery 헬퍼 추출, 6개 서비스 메서드 간소화 - Effect.gen → Effect.flatMap 전환: uploadFile, reserveGroup, addMessagesToGroup - Effect LSP diagnostic 반영: Schema.decodeUnknown + mapError, Effect.void, yieldable error 직접 yield Co-Authored-By: Claude Opus 4.6 (1M context) --- package.json | 1 + pnpm-lock.yaml | 9 +++ src/errors/defaultError.ts | 10 +-- src/lib/effectErrorHandler.ts | 28 +++++---- src/lib/schemaUtils.ts | 12 ++-- src/services/defaultService.ts | 37 +++++++++++ src/services/iam/iamService.ts | 66 +++++--------------- src/services/messages/groupService.ts | 81 +++++++++---------------- src/services/messages/messageService.ts | 63 ++++++------------- src/services/storage/storageService.ts | 17 ++---- tsconfig.json | 12 +++- 11 files changed, 152 insertions(+), 184 deletions(-) diff --git a/package.json b/package.json index 5dd6c162..05e88d91 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ }, "devDependencies": { "@biomejs/biome": "2.4.10", + "@effect/language-service": "^0.85.1", "@effect/vitest": "^0.29.0", "@types/node": "^25.5.2", "dotenv": "^17.4.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b3370fc..fadbb845 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,9 @@ importers: '@biomejs/biome': specifier: 2.4.10 version: 2.4.10 + '@effect/language-service': + specifier: ^0.85.1 + version: 0.85.1 '@effect/vitest': specifier: ^0.29.0 version: 0.29.0(effect@3.21.0)(vitest@4.1.2(@types/node@25.5.2)(vite@7.1.5(@types/node@25.5.2)(yaml@2.8.3))) @@ -98,6 +101,10 @@ packages: cpu: [x64] os: [win32] + '@effect/language-service@0.85.1': + resolution: {integrity: sha512-EXnJjIy6zQ3nUO/MZ+ynWUb8B895KZPotd1++oTs9JjDkplwM7cb6zo8Zq2zU6piwq+KflO7amXbEfj1UMpHkw==} + hasBin: true + '@effect/vitest@0.29.0': resolution: {integrity: sha512-DvWr1aeEcaZ8mtu8hNVb4e3rEYvGEwQSr7wsNrW53t6nKYjkmjRICcvVEsXUhjoCblRHSxRsRV0TOt0+UmcvaQ==} peerDependencies: @@ -1090,6 +1097,8 @@ snapshots: '@biomejs/cli-win32-x64@2.4.10': optional: true + '@effect/language-service@0.85.1': {} + '@effect/vitest@0.29.0(effect@3.21.0)(vitest@4.1.2(@types/node@25.5.2)(vite@7.1.5(@types/node@25.5.2)(yaml@2.8.3)))': dependencies: effect: 3.21.0 diff --git a/src/errors/defaultError.ts b/src/errors/defaultError.ts index 99de2656..d490410f 100644 --- a/src/errors/defaultError.ts +++ b/src/errors/defaultError.ts @@ -154,11 +154,11 @@ Response: ${this.responseBody?.substring(0, 500) ?? '(empty)'}`; export const isErrorResponse = (value: unknown): value is ErrorResponse => { if (value == null || typeof value !== 'object') return false; - const obj = value as Record; + if (!('errorCode' in value) || !('errorMessage' in value)) return false; return ( - typeof obj.errorCode === 'string' && - obj.errorCode !== '' && - typeof obj.errorMessage === 'string' && - obj.errorMessage !== '' + typeof value.errorCode === 'string' && + value.errorCode !== '' && + typeof value.errorMessage === 'string' && + value.errorMessage !== '' ); }; diff --git a/src/lib/effectErrorHandler.ts b/src/lib/effectErrorHandler.ts index 96f4f3f9..6a28447a 100644 --- a/src/lib/effectErrorHandler.ts +++ b/src/lib/effectErrorHandler.ts @@ -4,24 +4,30 @@ import { UnhandledExitError, } from '../errors/defaultError'; +const isTaggedDefect = ( + value: unknown, +): value is {readonly _tag: string; readonly message?: unknown} => + value !== null && + typeof value === 'object' && + '_tag' in value && + typeof value._tag === 'string'; + /** * Defect(예측되지 않은 에러)에서 정보 추출 */ const extractDefectInfo = ( defect: unknown, ): {summary: string; details: string} => { - if (defect !== null && typeof defect === 'object') { - const obj = defect as Record; - - if ('_tag' in defect && typeof obj._tag === 'string') { - const tag = obj._tag; - const message = 'message' in defect ? String(obj.message) : ''; - return { - summary: `${tag}${message ? `: ${message}` : ''}`, - details: `Tagged Error [${tag}]: ${JSON.stringify(defect, null, 2)}`, - }; - } + if (isTaggedDefect(defect)) { + const tag = defect._tag; + const message = defect.message != null ? String(defect.message) : ''; + return { + summary: `${tag}${message ? `: ${message}` : ''}`, + details: `Tagged Error [${tag}]: ${JSON.stringify(defect, null, 2)}`, + }; + } + if (defect !== null && typeof defect === 'object') { const keys = Object.keys(defect); const summary = keys.length > 0 diff --git a/src/lib/schemaUtils.ts b/src/lib/schemaUtils.ts index f77f6bef..0fccd7f6 100644 --- a/src/lib/schemaUtils.ts +++ b/src/lib/schemaUtils.ts @@ -11,13 +11,13 @@ export const decodeWithBadRequest = ( schema: Schema.Schema, data: unknown, ): Effect.Effect => - Effect.try({ - try: () => Schema.decodeUnknownSync(schema)(data), - catch: error => + Effect.mapError( + Schema.decodeUnknown(schema)(data), + error => new BadRequestError({ - message: error instanceof Error ? error.message : String(error), + message: error.message, }), - }); + ); /** * stringDateTransfer를 Effect로 감싸 InvalidDateError가 Defect가 되지 않도록 합니다. @@ -35,7 +35,7 @@ export const safeDateTransfer = ( message: error instanceof Error ? error.message : String(error), }), }) - : Effect.succeed(undefined); + : Effect.void; /** * formatWithTransfer를 Effect로 감싸 InvalidDateError가 Defect가 되지 않도록 합니다. diff --git a/src/services/defaultService.ts b/src/services/defaultService.ts index 4758f89b..8da9c7b2 100644 --- a/src/services/defaultService.ts +++ b/src/services/defaultService.ts @@ -1,11 +1,16 @@ import {AuthenticationParameter} from '@lib/authenticator'; import {defaultFetcherEffect} from '@lib/defaultFetcher'; import {runSafePromise} from '@lib/effectErrorHandler'; +import {decodeWithBadRequest, safeFinalize} from '@lib/schemaUtils'; +import stringifyQuery from '@lib/stringifyQuery'; +import {Schema} from 'effect'; import * as Effect from 'effect/Effect'; import type { ApiKeyError, + BadRequestError, ClientError, DefaultError, + InvalidDateError, NetworkError, ServerError, } from '../errors/defaultError'; @@ -51,4 +56,36 @@ export default class DefaultService { ): Promise { return runSafePromise(this.requestEffect(parameter)); } + + protected getWithQuery(config: { + schema: Schema.Schema; + finalize: (validated?: A) => object; + url: string; + data?: unknown; + }): Effect.Effect< + R, + | ApiKeyError + | ClientError + | ServerError + | NetworkError + | DefaultError + | BadRequestError + | InvalidDateError + > { + const reqEffect = this.requestEffect.bind(this); + return Effect.gen(function* () { + const validated = config.data + ? yield* decodeWithBadRequest(config.schema, config.data) + : undefined; + const payload = yield* safeFinalize(() => config.finalize(validated)); + const parameter = stringifyQuery(payload, { + indices: false, + addQueryPrefix: true, + }); + return yield* reqEffect({ + httpMethod: 'GET', + url: `${config.url}${parameter}`, + }); + }); + } } diff --git a/src/services/iam/iamService.ts b/src/services/iam/iamService.ts index 724a75fd..1bcddadb 100644 --- a/src/services/iam/iamService.ts +++ b/src/services/iam/iamService.ts @@ -1,6 +1,4 @@ import {runSafePromise} from '@lib/effectErrorHandler'; -import {decodeWithBadRequest, safeFinalize} from '@lib/schemaUtils'; -import stringifyQuery from '@lib/stringifyQuery'; import { finalizeGetBlacksRequest, type GetBlacksRequest, @@ -19,7 +17,6 @@ import { import {GetBlacksResponse} from '@models/responses/iam/getBlacksResponse'; import {GetBlockGroupsResponse} from '@models/responses/iam/getBlockGroupsResponse'; import {GetBlockNumbersResponse} from '@models/responses/iam/getBlockNumbersResponse'; -import * as Effect from 'effect/Effect'; import DefaultService from '../defaultService'; export default class IamService extends DefaultService { @@ -29,23 +26,12 @@ export default class IamService extends DefaultService { * @returns GetBlacksResponse */ async getBlacks(data?: GetBlacksRequest): Promise { - const reqEffect = this.requestEffect.bind(this); return runSafePromise( - Effect.gen(function* () { - const validated = data - ? yield* decodeWithBadRequest(getBlacksRequestSchema, data) - : undefined; - const payload = yield* safeFinalize(() => - finalizeGetBlacksRequest(validated), - ); - const parameter = stringifyQuery(payload, { - indices: false, - addQueryPrefix: true, - }); - return yield* reqEffect({ - httpMethod: 'GET', - url: `iam/v1/black${parameter}`, - }); + this.getWithQuery({ + schema: getBlacksRequestSchema, + finalize: finalizeGetBlacksRequest, + url: 'iam/v1/black', + data, }), ); } @@ -58,23 +44,12 @@ export default class IamService extends DefaultService { async getBlockGroups( data?: GetBlockGroupsRequest, ): Promise { - const reqEffect = this.requestEffect.bind(this); return runSafePromise( - Effect.gen(function* () { - const validated = data - ? yield* decodeWithBadRequest(getBlockGroupsRequestSchema, data) - : undefined; - const payload = yield* safeFinalize(() => - finalizeGetBlockGroupsRequest(validated), - ); - const parameter = stringifyQuery(payload, { - indices: false, - addQueryPrefix: true, - }); - return yield* reqEffect({ - httpMethod: 'GET', - url: `iam/v1/block/groups${parameter}`, - }); + this.getWithQuery({ + schema: getBlockGroupsRequestSchema, + finalize: finalizeGetBlockGroupsRequest, + url: 'iam/v1/block/groups', + data, }), ); } @@ -87,23 +62,12 @@ export default class IamService extends DefaultService { async getBlockNumbers( data?: GetBlockNumbersRequest, ): Promise { - const reqEffect = this.requestEffect.bind(this); return runSafePromise( - Effect.gen(function* () { - const validated = data - ? yield* decodeWithBadRequest(getBlockNumbersRequestSchema, data) - : undefined; - const payload = yield* safeFinalize(() => - finalizeGetBlockNumbersRequest(validated), - ); - const parameter = stringifyQuery(payload, { - indices: false, - addQueryPrefix: true, - }); - return yield* reqEffect({ - httpMethod: 'GET', - url: `iam/v1/block/numbers${parameter}`, - }); + this.getWithQuery({ + schema: getBlockNumbersRequestSchema, + finalize: finalizeGetBlockNumbersRequest, + url: 'iam/v1/block/numbers', + data, }), ); } diff --git a/src/services/messages/groupService.ts b/src/services/messages/groupService.ts index 758c5641..0528d071 100644 --- a/src/services/messages/groupService.ts +++ b/src/services/messages/groupService.ts @@ -1,10 +1,6 @@ import {GroupId} from '@internal-types/commonTypes'; import {runSafePromise} from '@lib/effectErrorHandler'; -import { - decodeWithBadRequest, - safeFinalize, - safeFormatWithTransfer, -} from '@lib/schemaUtils'; +import {decodeWithBadRequest, safeFormatWithTransfer} from '@lib/schemaUtils'; import stringifyQuery from '@lib/stringifyQuery'; import { finalizeGetGroupsRequest, @@ -80,24 +76,19 @@ export default class GroupService extends DefaultService { ): Promise { const reqEffect = this.requestEffect.bind(this); return runSafePromise( - Effect.gen(function* () { - const validatedMessages = yield* decodeWithBadRequest( - requestSendMessageSchema, - messages, - ); - - const requestBody: GroupMessageAddRequest = { - messages: Array.isArray(validatedMessages) - ? validatedMessages - : [validatedMessages], - }; - - return yield* reqEffect({ - httpMethod: 'PUT', - url: `messages/v4/groups/${groupId}/messages`, - body: requestBody, - }); - }), + Effect.flatMap( + decodeWithBadRequest(requestSendMessageSchema, messages), + validatedMessages => + reqEffect({ + httpMethod: 'PUT', + url: `messages/v4/groups/${groupId}/messages`, + body: { + messages: Array.isArray(validatedMessages) + ? validatedMessages + : [validatedMessages], + }, + }), + ), ); } @@ -122,20 +113,15 @@ export default class GroupService extends DefaultService { async reserveGroup(groupId: GroupId, scheduledDate: Date | string) { const reqEffect = this.requestEffect.bind(this); return runSafePromise( - Effect.gen(function* () { - const formattedScheduledDate = - yield* safeFormatWithTransfer(scheduledDate); - return yield* reqEffect< - ScheduledDateSendingRequest, - GroupMessageResponse - >({ - httpMethod: 'POST', - url: `messages/v4/groups/${groupId}/schedule`, - body: { - scheduledDate: formattedScheduledDate, - }, - }); - }), + Effect.flatMap( + safeFormatWithTransfer(scheduledDate), + formattedScheduledDate => + reqEffect({ + httpMethod: 'POST', + url: `messages/v4/groups/${groupId}/schedule`, + body: {scheduledDate: formattedScheduledDate}, + }), + ), ); } @@ -159,23 +145,12 @@ export default class GroupService extends DefaultService { * @param data 그룹 정보 상세 조회용 request 데이터 */ async getGroups(data?: GetGroupsRequest): Promise { - const reqEffect = this.requestEffect.bind(this); return runSafePromise( - Effect.gen(function* () { - const validated = data - ? yield* decodeWithBadRequest(getGroupsRequestSchema, data) - : undefined; - const payload = yield* safeFinalize(() => - finalizeGetGroupsRequest(validated), - ); - const parameter = stringifyQuery(payload, { - indices: false, - addQueryPrefix: true, - }); - return yield* reqEffect({ - httpMethod: 'GET', - url: `messages/v4/groups${parameter}`, - }); + this.getWithQuery({ + schema: getGroupsRequestSchema, + finalize: finalizeGetGroupsRequest, + url: 'messages/v4/groups', + data, }), ); } diff --git a/src/services/messages/messageService.ts b/src/services/messages/messageService.ts index 453111ae..9b71d458 100644 --- a/src/services/messages/messageService.ts +++ b/src/services/messages/messageService.ts @@ -1,6 +1,5 @@ import {runSafePromise} from '@lib/effectErrorHandler'; -import {decodeWithBadRequest, safeFinalize} from '@lib/schemaUtils'; -import stringifyQuery from '@lib/stringifyQuery'; +import {decodeWithBadRequest} from '@lib/schemaUtils'; import { finalizeGetMessagesRequest, type GetMessagesRequest, @@ -62,11 +61,9 @@ export default class MessageService extends DefaultService { : [messageSchema]; if (messageParameters.length === 0) { - return yield* Effect.fail( - new BadRequestError({ - message: '데이터가 반드시 1건 이상 기입되어 있어야 합니다.', - }), - ); + return yield* new BadRequestError({ + message: '데이터가 반드시 1건 이상 기입되어 있어야 합니다.', + }); } const decodedConfig = yield* decodeWithBadRequest( @@ -104,12 +101,10 @@ export default class MessageService extends DefaultService { count.total === count.registeredFailed; if (failedAll) { - return yield* Effect.fail( - new MessageNotReceivedError({ - failedMessageList: response.failedMessageList, - totalCount: response.failedMessageList.length, - }), - ); + return yield* new MessageNotReceivedError({ + failedMessageList: response.failedMessageList, + totalCount: response.failedMessageList.length, + }); } return response; @@ -124,23 +119,12 @@ export default class MessageService extends DefaultService { async getMessages( data?: Readonly, ): Promise { - const reqEffect = this.requestEffect.bind(this); return runSafePromise( - Effect.gen(function* () { - const validated = data - ? yield* decodeWithBadRequest(getMessagesRequestSchema, data) - : undefined; - const payload = yield* safeFinalize(() => - finalizeGetMessagesRequest(validated), - ); - const parameter = stringifyQuery(payload, { - indices: false, - addQueryPrefix: true, - }); - return yield* reqEffect({ - httpMethod: 'GET', - url: `messages/v4/list${parameter}`, - }); + this.getWithQuery({ + schema: getMessagesRequestSchema, + finalize: finalizeGetMessagesRequest, + url: 'messages/v4/list', + data, }), ); } @@ -153,23 +137,12 @@ export default class MessageService extends DefaultService { async getStatistics( data?: Readonly, ): Promise { - const reqEffect = this.requestEffect.bind(this); return runSafePromise( - Effect.gen(function* () { - const validated = data - ? yield* decodeWithBadRequest(getStatisticsRequestSchema, data) - : undefined; - const payload = yield* safeFinalize(() => - finalizeGetStatisticsRequest(validated), - ); - const parameter = stringifyQuery(payload, { - indices: false, - addQueryPrefix: true, - }); - return yield* reqEffect({ - httpMethod: 'GET', - url: `messages/v4/statistics${parameter}`, - }); + this.getWithQuery({ + schema: getStatisticsRequestSchema, + finalize: finalizeGetStatisticsRequest, + url: 'messages/v4/statistics', + data, }), ); } diff --git a/src/services/storage/storageService.ts b/src/services/storage/storageService.ts index 2bc173f7..83640846 100644 --- a/src/services/storage/storageService.ts +++ b/src/services/storage/storageService.ts @@ -25,20 +25,13 @@ export default class StorageService extends DefaultService { ): Promise { const reqEffect = this.requestEffect.bind(this); return runSafePromise( - Effect.gen(function* () { - const encodedFile = yield* fileToBase64Effect(filePath); - const parameter: FileUploadRequest = { - file: encodedFile, - type: fileType, - name, - link, - }; - return yield* reqEffect({ + Effect.flatMap(fileToBase64Effect(filePath), encodedFile => + reqEffect({ httpMethod: 'POST', url: 'storage/v1/files', - body: parameter, - }); - }), + body: {file: encodedFile, type: fileType, name, link}, + }), + ), ); } } diff --git a/tsconfig.json b/tsconfig.json index ea804d50..a35f4aad 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -44,6 +44,16 @@ /* Additional Type Checking */ "noUnusedLocals": true, "noUnusedParameters": true, - "noImplicitReturns": true + "noImplicitReturns": true, + + /* Effect Language Service */ + "plugins": [ + { + "name": "@effect/language-service", + "diagnosticSeverity": { + "preferSchemaOverJson": "off" + } + } + ] } } From 2faa1d139299123388345a8fa822222fe7a8491f Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Tue, 14 Apr 2026 18:56:34 +0900 Subject: [PATCH 16/24] =?UTF-8?q?refactor:=20=EB=AF=B8=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=20export=20=EB=B0=8F=20dead=20file=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit operatorTypeSchema, dateOperatorTypeSchema, kakaoOptionRequestSchema 등 프로젝트 내부·외부 어디에서도 참조되지 않는 타입/스키마 export를 정리하고, 유일한 export가 모두 dead인 kakaoOptionRequest.ts 파일을 삭제합니다. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/models/requests/index.ts | 4 --- .../requests/kakao/kakaoOptionRequest.ts | 17 ---------- src/types/commonTypes.ts | 33 ------------------- src/types/index.ts | 4 --- 4 files changed, 58 deletions(-) delete mode 100644 src/models/requests/kakao/kakaoOptionRequest.ts diff --git a/src/models/requests/index.ts b/src/models/requests/index.ts index 36436d27..5b3ebcdb 100644 --- a/src/models/requests/index.ts +++ b/src/models/requests/index.ts @@ -43,10 +43,6 @@ export { type GetKakaoChannelsRequest, getKakaoChannelsRequestSchema, } from './kakao/getKakaoChannelsRequest'; -export { - type KakaoOptionRequest, - kakaoOptionRequestSchema, -} from './kakao/kakaoOptionRequest'; export { type UpdateKakaoAlimtalkTemplateRequest, updateKakaoAlimtalkTemplateRequestSchema, diff --git a/src/models/requests/kakao/kakaoOptionRequest.ts b/src/models/requests/kakao/kakaoOptionRequest.ts deleted file mode 100644 index ca349437..00000000 --- a/src/models/requests/kakao/kakaoOptionRequest.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {kakaoButtonSchema} from '@models/base/kakao/kakaoButton'; -import {Schema} from 'effect'; - -export const kakaoOptionRequestSchema = Schema.Struct({ - pfId: Schema.String, - templateId: Schema.optional(Schema.String), - variables: Schema.optional( - Schema.Record({key: Schema.String, value: Schema.String}), - ), - disableSms: Schema.optional(Schema.Boolean), - adFlag: Schema.optional(Schema.Boolean), - buttons: Schema.optional(Schema.Array(kakaoButtonSchema)), - imageId: Schema.optional(Schema.String), -}); -export type KakaoOptionRequest = Schema.Schema.Type< - typeof kakaoOptionRequestSchema ->; diff --git a/src/types/commonTypes.ts b/src/types/commonTypes.ts index 239c2015..17870756 100644 --- a/src/types/commonTypes.ts +++ b/src/types/commonTypes.ts @@ -1,38 +1,5 @@ import {Schema} from 'effect'; -// --- Operator Types --- - -/** - * @description 검색 조건 파라미터 - * @see https://developers.solapi.com/references/#operator - */ -export const operatorTypeSchema = Schema.Literal( - 'eq', - 'gte', - 'lte', - 'ne', - 'in', - 'like', - 'gt', - 'lt', -); -export type OperatorType = Schema.Schema.Type; - -/** - * @description 날짜 검색 조건 파라미터 - * @see https://developers.solapi.com/references/#operator - */ -export const dateOperatorTypeSchema = Schema.Literal( - 'eq', - 'gte', - 'lte', - 'gt', - 'lt', -); -export type DateOperatorType = Schema.Schema.Type< - typeof dateOperatorTypeSchema ->; - // --- Count & Charge Types --- export const countSchema = Schema.Struct({ diff --git a/src/types/index.ts b/src/types/index.ts index 4a2d032d..fb6e415e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -13,8 +13,6 @@ export { commonCashResponseSchema, countForChargeSchema, countSchema, - type DateOperatorType, - dateOperatorTypeSchema, type Group, type GroupId, groupIdSchema, @@ -25,6 +23,4 @@ export { logSchema, type MessageTypeRecord, messageTypeRecordSchema, - type OperatorType, - operatorTypeSchema, } from './commonTypes'; From 1a2214cf3e8573003cd6749bace46631bc51b0a6 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Wed, 15 Apr 2026 09:50:48 +0900 Subject: [PATCH 17/24] =?UTF-8?q?refactor:=20Schema.transformOrFail=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=20=EB=B0=8F=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=A3=BC=EC=84=9D=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Effect 공식문서 원칙에 따라 throw 가능한 함수를 사용하는 Schema.transform을 Schema.transformOrFail로 전환하고, CLAUDE.md "코드가 자체 설명적이어야 함" 원칙에 따라 불필요한 what/how 주석을 제거한다. - kakaoOption: runSafeSync + Schema.transform → Schema.transformOrFail + Effect.mapError - requestConfig: formatWithTransfer + Schema.transform → safeFormatWithTransfer + Schema.transformOrFail - 10개 파일에서 불필요한 섹션 구분·단계별·설명 주석 ~35건 제거 (JSDoc API 문서 보존) - kakaoOption.test: performance.now() 비결정적 테스트 → 결정적 정확성 테스트 - stringifyQuery.test: @effect/vitest → vitest import (순수 유닛 테스트) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/index.ts | 17 ----------- src/lib/defaultFetcher.ts | 1 - src/lib/effectErrorHandler.ts | 1 - src/lib/fileToBase64.ts | 3 -- src/lib/stringifyQuery.ts | 4 --- src/models/base/kakao/kakaoOption.ts | 28 +++++++++++-------- src/models/requests/messages/requestConfig.ts | 20 ++++++------- src/models/requests/messages/sendMessage.ts | 5 ---- src/services/messages/messageService.ts | 4 --- test/lib/stringifyQuery.test.ts | 2 +- test/models/base/kakao/kakaoOption.test.ts | 5 +--- 11 files changed, 28 insertions(+), 62 deletions(-) diff --git a/src/index.ts b/src/index.ts index dddecd41..d5070ca5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,11 +7,8 @@ import MessageService from '@services/messages/messageService'; import StorageService from '@services/storage/storageService'; import {ApiKeyError} from './errors/defaultError'; -// Errors export * from './errors/defaultError'; -// Models (base types, request types, response types, schemas) export * from './models/index'; -// Common Types & Schemas export * from './types/index'; /** @@ -22,14 +19,12 @@ export * from './types/index'; * @see https://developers.solapi.com/category/nodejs */ export class SolapiMessageService { - // CashService 위임 /** * 잔액조회 * @returns GetBalanceResponse */ readonly getBalance: typeof CashService.prototype.getBalance; - // IamService 위임 /** * 080 수신 거부 조회 * @param data 080 수신 거부 상세 조회용 request 데이터 @@ -51,7 +46,6 @@ export class SolapiMessageService { */ readonly getBlockNumbers: typeof IamService.prototype.getBlockNumbers; - // KakaoChannelService 위임 /** * 카카오 채널 카테고리 조회 */ @@ -86,7 +80,6 @@ export class SolapiMessageService { */ readonly removeKakaoChannel: typeof KakaoChannelService.prototype.removeKakaoChannel; - // KakaoTemplateService 위임 /** * 카카오 템플릿 카테고리 조회 */ @@ -137,7 +130,6 @@ export class SolapiMessageService { */ readonly removeKakaoAlimtalkTemplate: typeof KakaoTemplateService.prototype.removeKakaoAlimtalkTemplate; - // GroupService 위임 /** * 그룹 생성 * @param allowDuplicates 생성할 그룹이 중복 수신번호를 허용하는지 여부를 확인합니다. @@ -206,7 +198,6 @@ export class SolapiMessageService { */ readonly removeGroup: typeof GroupService.prototype.removeGroup; - // MessageService 위임 /** * 메시지 발송 기능, sendMany 함수보다 개선된 오류 표시 기능등을 제공합니다. * 한번의 요청으로 최대 10,000건까지 발송할 수 있습니다. @@ -230,7 +221,6 @@ export class SolapiMessageService { */ readonly getStatistics: typeof MessageService.prototype.getStatistics; - // StorageService 위임 /** * 파일(이미지) 업로드 * 카카오 친구톡 이미지는 500kb, MMS는 200kb, 발신번호 서류 인증용 파일은 2mb의 제한이 있음 @@ -256,15 +246,12 @@ export class SolapiMessageService { const messageService = new MessageService(apiKey, apiSecret); const storageService = new StorageService(apiKey, apiSecret); - // CashService this.getBalance = cashService.getBalance.bind(cashService); - // IamService this.getBlacks = iamService.getBlacks.bind(iamService); this.getBlockGroups = iamService.getBlockGroups.bind(iamService); this.getBlockNumbers = iamService.getBlockNumbers.bind(iamService); - // KakaoChannelService this.getKakaoChannelCategories = kakaoChannelService.getKakaoChannelCategories.bind(kakaoChannelService); this.getKakaoChannels = @@ -278,7 +265,6 @@ export class SolapiMessageService { this.removeKakaoChannel = kakaoChannelService.removeKakaoChannel.bind(kakaoChannelService); - // KakaoTemplateService this.getKakaoAlimtalkTemplateCategories = kakaoTemplateService.getKakaoAlimtalkTemplateCategories.bind( kakaoTemplateService, @@ -308,7 +294,6 @@ export class SolapiMessageService { kakaoTemplateService, ); - // GroupService this.createGroup = groupService.createGroup.bind(groupService); this.addMessagesToGroup = groupService.addMessagesToGroup.bind(groupService); @@ -323,12 +308,10 @@ export class SolapiMessageService { groupService.removeGroupMessages.bind(groupService); this.removeGroup = groupService.removeGroup.bind(groupService); - // MessageService this.send = messageService.send.bind(messageService); this.getMessages = messageService.getMessages.bind(messageService); this.getStatistics = messageService.getStatistics.bind(messageService); - // StorageService this.uploadFile = storageService.uploadFile.bind(storageService); } } diff --git a/src/lib/defaultFetcher.ts b/src/lib/defaultFetcher.ts index 9493f5f2..8fba3c6d 100644 --- a/src/lib/defaultFetcher.ts +++ b/src/lib/defaultFetcher.ts @@ -14,7 +14,6 @@ type DefaultRequest = { method: string; }; -// Effect Data 타입으로 RetryableError 정의 class RetryableError extends Data.TaggedError('RetryableError')<{ readonly error?: unknown; }> {} diff --git a/src/lib/effectErrorHandler.ts b/src/lib/effectErrorHandler.ts index 6a28447a..f7a83362 100644 --- a/src/lib/effectErrorHandler.ts +++ b/src/lib/effectErrorHandler.ts @@ -86,7 +86,6 @@ export const runSafeSync = (effect: Effect.Effect): A => { }); }; -// Promise로 Effect 실행 — 예측된 실패는 원본 Effect 에러 그대로 reject export const runSafePromise = ( effect: Effect.Effect, ): Promise => { diff --git a/src/lib/fileToBase64.ts b/src/lib/fileToBase64.ts index 7582f8e2..af9a7f7f 100644 --- a/src/lib/fileToBase64.ts +++ b/src/lib/fileToBase64.ts @@ -3,7 +3,6 @@ import {URL} from 'node:url'; import * as Effect from 'effect/Effect'; import {DefaultError} from '../errors/defaultError'; -// 내부 유틸: 주어진 문자열이 http(s) 스킴의 URL 인지 판별 const isHttpUrl = (value: string): boolean => { try { const url = new URL(value); @@ -13,7 +12,6 @@ const isHttpUrl = (value: string): boolean => { } }; -// URL → Base64 변환 const fromUrl = (url: string) => Effect.flatMap( Effect.tryPromise({ @@ -49,7 +47,6 @@ const fromUrl = (url: string) => Effect.map(arrayBuffer => Buffer.from(arrayBuffer).toString('base64')), ); -// 파일 경로 → Base64 변환 const fromPath = (path: string) => Effect.tryPromise({ try: () => fs.readFile(path), diff --git a/src/lib/stringifyQuery.ts b/src/lib/stringifyQuery.ts index dc1e6245..c719b4ff 100644 --- a/src/lib/stringifyQuery.ts +++ b/src/lib/stringifyQuery.ts @@ -35,12 +35,10 @@ export default function stringifyQuery( return ''; } - // 빈 객체인 경우 빈 문자열 반환 (쿼리 파라미터가 없으므로 접두사도 불필요) if (Object.keys(obj).length === 0) { return ''; } - // 값 직렬화를 위한 내부 함수 (nested object 지원) const processValue = (key: string, value: unknown): string[] => { if (Array.isArray(value)) { if (options.indices === false) { @@ -79,8 +77,6 @@ export default function stringifyQuery( const queryString = pairs.join('&'); - // 쿼리 스트링이 있으면 기본적으로 '?' 접두사를 붙임 - // addQueryPrefix가 명시적으로 false로 설정된 경우에만 접두사 없이 반환 if (queryString) { return options.addQueryPrefix === false ? queryString : `?${queryString}`; } diff --git a/src/models/base/kakao/kakaoOption.ts b/src/models/base/kakao/kakaoOption.ts index 081b3ac3..2c3136d0 100644 --- a/src/models/base/kakao/kakaoOption.ts +++ b/src/models/base/kakao/kakaoOption.ts @@ -1,5 +1,11 @@ -import {runSafeSync} from '@lib/effectErrorHandler'; -import {Data, Effect, Array as EffectArray, pipe, Schema} from 'effect'; +import { + Data, + Effect, + Array as EffectArray, + ParseResult, + pipe, + Schema, +} from 'effect'; import { bmsButtonSchema, bmsCarouselCommerceSchema, @@ -12,7 +18,6 @@ import { } from './bms'; import {kakaoButtonSchema} from './kakaoButton'; -// Effect Data 타입을 활용한 에러 클래스 export class VariableValidationError extends Data.TaggedError( 'VariableValidationError', )<{ @@ -146,14 +151,12 @@ export type KakaoOptionBmsSchema = Schema.Schema.Type< const VARIABLE_KEY_PATTERN = /^#\{.+}$/; const DOT_PATTERN = /\./; -// Pure helper functions optimized with Effect const extractVariableName = (key: string): string => VARIABLE_KEY_PATTERN.test(key) ? key.slice(2, -1) : key; const formatVariableKey = (key: string): string => VARIABLE_KEY_PATTERN.test(key) ? key : `#{${key}}`; -// Effect-based validation that returns Either instead of throwing export const validateVariableNames = ( variables: Record, ): Effect.Effect, VariableValidationError> => @@ -167,7 +170,6 @@ export const validateVariableNames = ( : Effect.succeed(variables), ); -// Optimized transformation function using Effect pipeline export const transformVariables = ( variables: Record, ): Effect.Effect, VariableValidationError> => @@ -189,14 +191,16 @@ export const baseKakaoOptionSchema = Schema.Struct({ templateId: Schema.optional(Schema.String), variables: Schema.optional( Schema.Record({key: Schema.String, value: Schema.String}).pipe( - Schema.transform( + Schema.transformOrFail( Schema.Record({key: Schema.String, value: Schema.String}), { - decode: fromU => { - // runSafeSync를 사용하여 깔끔한 에러 메시지 제공 - return runSafeSync(transformVariables(fromU)); - }, - encode: toI => toI, + decode: (fromU, _, ast) => + transformVariables(fromU).pipe( + Effect.mapError( + err => new ParseResult.Type(ast, fromU, err.message), + ), + ), + encode: toI => ParseResult.succeed(toI), }, ), ), diff --git a/src/models/requests/messages/requestConfig.ts b/src/models/requests/messages/requestConfig.ts index 6a196f88..a3c2df51 100644 --- a/src/models/requests/messages/requestConfig.ts +++ b/src/models/requests/messages/requestConfig.ts @@ -1,19 +1,16 @@ -import {formatWithTransfer} from '@lib/stringDateTransfer'; -import {Schema} from 'effect'; +import {safeFormatWithTransfer} from '@lib/schemaUtils'; +import {Effect, ParseResult, Schema} from 'effect'; import pkg from '../../../../package.json'; -// SDK 및 OS 정보 export const osPlatform = `${process.platform} | ${process.version}`; export const sdkVersion = `nodejs/${pkg.version}`; -// Agent 정보 타입 export type DefaultAgentType = { sdkVersion: string; osPlatform: string; appId?: string; }; -// Agent 정보 Effect 스키마 export const defaultAgentTypeSchema = Schema.Struct({ sdkVersion: Schema.optional(Schema.String).pipe( Schema.withDecodingDefault(() => sdkVersion), @@ -26,13 +23,17 @@ export const defaultAgentTypeSchema = Schema.Struct({ appId: Schema.optional(Schema.String), }); -// send 요청 시 사용되는 Config 스키마 export const sendRequestConfigSchema = Schema.Struct({ scheduledDate: Schema.optional( Schema.Union(Schema.DateFromSelf, Schema.DateFromString).pipe( - Schema.transform(Schema.String, { - decode: fromA => formatWithTransfer(fromA), - encode: toI => new Date(toI), + Schema.transformOrFail(Schema.String, { + decode: (fromA, _, ast) => + safeFormatWithTransfer(fromA).pipe( + Effect.mapError( + err => new ParseResult.Type(ast, fromA, err.message), + ), + ), + encode: toI => ParseResult.succeed(new Date(toI)), }), ), ), @@ -45,7 +46,6 @@ export type SendRequestConfigSchema = Schema.Schema.Type< typeof sendRequestConfigSchema >; -// 메시지 요청 시 공통으로 사용하는 기본 스키마 export const defaultMessageRequestSchema = Schema.Struct({ allowDuplicates: Schema.optional(Schema.Boolean), agent: Schema.optional(defaultAgentTypeSchema), diff --git a/src/models/requests/messages/sendMessage.ts b/src/models/requests/messages/sendMessage.ts index 1ed72e28..f3295282 100644 --- a/src/models/requests/messages/sendMessage.ts +++ b/src/models/requests/messages/sendMessage.ts @@ -9,18 +9,15 @@ export const phoneNumberSchema = Schema.String.pipe( decode: removeHyphens, encode: s => s, }), - // 하이픈 제거 이후 값이 비어있지 않은지 확인 (예: "---" -> "") Schema.filter(s => s.trim().length > 0, { message: () => '전화번호는 빈 문자열일 수 없습니다.', }), - // 숫자 및 하이픈만 허용하도록 강제. 하이픈 제거 후에는 숫자만 남아야 함 Schema.filter(s => /^[0-9]+$/.test(s), { message: () => '전화번호는 숫자 및 특수문자 - 외 문자를 포함할 수 없습니다.', }), ); -// 빈 배열 검증을 위한 재사용 가능한 필터 const nonEmptyArrayFilter = (schema: Schema.Schema) => Schema.Array(schema).pipe( Schema.filter(arr => arr.length > 0, { @@ -84,10 +81,8 @@ export type RequestSendMessagesSchema = Schema.Schema.Type< typeof requestSendMessageSchema >; -// 기본 Agent 객체 (sdkVersion, osPlatform 값 포함) – 빈 객체 디코딩으로 생성 const defaultAgentValue = Schema.decodeSync(defaultAgentTypeSchema)({}); -// Agent 스키마의 재사용 가능한 정의 const agentWithDefaultSchema = Schema.optional(defaultAgentTypeSchema).pipe( Schema.withDecodingDefault(() => defaultAgentValue), Schema.withConstructorDefault(() => defaultAgentValue), diff --git a/src/services/messages/messageService.ts b/src/services/messages/messageService.ts index 9b71d458..0a685854 100644 --- a/src/services/messages/messageService.ts +++ b/src/services/messages/messageService.ts @@ -49,13 +49,11 @@ export default class MessageService extends DefaultService { return runSafePromise( Effect.gen(function* () { - // 1. 스키마 검증 const messageSchema = yield* decodeWithBadRequest( requestSendMessageSchema, messages, ); - // 2. MessageParameter -> Message 변환 및 기본 검증 const messageParameters = Array.isArray(messageSchema) ? messageSchema : [messageSchema]; @@ -84,7 +82,6 @@ export default class MessageService extends DefaultService { parameterObject, ); - // 3. API 호출 const response = yield* reqEffect< MultipleMessageSendingRequestSchema, DetailGroupMessageResponse @@ -94,7 +91,6 @@ export default class MessageService extends DefaultService { body: parameter, }); - // 4. 모든 메시지 발송건이 실패인 경우 MessageNotReceivedError 반환 const {count} = response.groupInfo; const failedAll = response.failedMessageList.length > 0 && diff --git a/test/lib/stringifyQuery.test.ts b/test/lib/stringifyQuery.test.ts index 74e14e2a..802a639d 100644 --- a/test/lib/stringifyQuery.test.ts +++ b/test/lib/stringifyQuery.test.ts @@ -1,4 +1,4 @@ -import {describe, expect, it} from '@effect/vitest'; +import {describe, expect, it} from 'vitest'; import stringifyQuery from '@/lib/stringifyQuery'; describe('stringifyQuery', () => { diff --git a/test/models/base/kakao/kakaoOption.test.ts b/test/models/base/kakao/kakaoOption.test.ts index dbb61d6f..e3d5384f 100644 --- a/test/models/base/kakao/kakaoOption.test.ts +++ b/test/models/base/kakao/kakaoOption.test.ts @@ -195,18 +195,15 @@ describe('Effect-based variable validation (new functionality)', () => { expect(transformResult).toEqual({}); }); - it('should be performant with large variable sets', async () => { + it('should handle large variable sets correctly', async () => { const largeVariableSet = Object.fromEntries( Array.from({length: 1000}, (_, i) => [`var_${i}`, `value_${i}`]), ); - const startTime = performance.now(); const result = await Effect.runPromise( transformVariables(largeVariableSet), ); - const endTime = performance.now(); - expect(endTime - startTime).toBeLessThan(100); // Should complete in under 100ms expect(Object.keys(result)).toHaveLength(1000); expect(result['#{var_0}']).toBe('value_0'); expect(result['#{var_999}']).toBe('value_999'); From 36354052621e6246906b4c17510c1f620f531ef8 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Wed, 15 Apr 2026 10:05:44 +0900 Subject: [PATCH 18/24] =?UTF-8?q?fix:=20=EB=A6=AC=EB=B7=B0=20=ED=94=BC?= =?UTF-8?q?=EB=93=9C=EB=B0=B1=20=EB=B0=98=EC=98=81=20=E2=80=94=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=20=EB=88=84=EB=9D=BD=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F?= =?UTF-8?q?=20sendRequestConfigSchema=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - kakaoOption.ts: 남아있던 "Constants for variable validation" 주석 제거 - sendMessage.test.ts: sendRequestConfigSchema의 decode/encode 경로 테스트 5건 추가 (Date→string 변환, string→string 변환, 잘못된 날짜 실패, 선택 필드, encode round-trip) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/models/base/kakao/kakaoOption.ts | 1 - .../requests/messages/sendMessage.test.ts | 53 ++++++++++++++++++- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/models/base/kakao/kakaoOption.ts b/src/models/base/kakao/kakaoOption.ts index 2c3136d0..e87de302 100644 --- a/src/models/base/kakao/kakaoOption.ts +++ b/src/models/base/kakao/kakaoOption.ts @@ -147,7 +147,6 @@ export type KakaoOptionBmsSchema = Schema.Schema.Type< typeof kakaoOptionBmsSchema >; -// Constants for variable validation const VARIABLE_KEY_PATTERN = /^#\{.+}$/; const DOT_PATTERN = /\./; diff --git a/test/models/requests/messages/sendMessage.test.ts b/test/models/requests/messages/sendMessage.test.ts index 6ccfe434..cc816dba 100644 --- a/test/models/requests/messages/sendMessage.test.ts +++ b/test/models/requests/messages/sendMessage.test.ts @@ -1,5 +1,6 @@ -import {Schema} from 'effect'; +import {Either, Schema} from 'effect'; import {describe, expect, it} from 'vitest'; +import {sendRequestConfigSchema} from '@/models/requests/messages/requestConfig'; import { multipleMessageSendingRequestSchema, phoneNumberSchema, @@ -535,3 +536,53 @@ describe('Effect Schema Integration Tests', () => { }); }); }); + +describe('sendRequestConfigSchema', () => { + it('should decode scheduledDate from Date to ISO string', () => { + const futureDate = new Date('2025-06-15T10:30:00.000Z'); + const result = Schema.decodeUnknownSync(sendRequestConfigSchema)({ + scheduledDate: futureDate, + }); + + expect(typeof result.scheduledDate).toBe('string'); + expect(result.scheduledDate).toContain('2025-06-15'); + }); + + it('should decode scheduledDate from string to ISO string', () => { + const dateString = '2025-06-15T10:30:00.000Z'; + const result = Schema.decodeUnknownSync(sendRequestConfigSchema)({ + scheduledDate: dateString, + }); + + expect(typeof result.scheduledDate).toBe('string'); + expect(result.scheduledDate).toContain('2025-06-15'); + }); + + it('should fail for invalid scheduledDate string', () => { + const result = Schema.decodeUnknownEither(sendRequestConfigSchema)({ + scheduledDate: 'not-a-date', + }); + + expect(Either.isLeft(result)).toBe(true); + }); + + it('should decode without scheduledDate', () => { + const result = Schema.decodeUnknownSync(sendRequestConfigSchema)({ + allowDuplicates: true, + appId: 'test-app', + }); + + expect(result.scheduledDate).toBeUndefined(); + expect(result.allowDuplicates).toBe(true); + expect(result.appId).toBe('test-app'); + }); + + it('should encode scheduledDate string back to Date', () => { + const decoded = Schema.decodeUnknownSync(sendRequestConfigSchema)({ + scheduledDate: new Date('2025-06-15T10:30:00.000Z'), + }); + const encoded = Schema.encodeSync(sendRequestConfigSchema)(decoded); + + expect(encoded.scheduledDate).toBeInstanceOf(Date); + }); +}); From 148df2332d24a457587115d8aad11044f02961f3 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Wed, 15 Apr 2026 10:11:55 +0900 Subject: [PATCH 19/24] =?UTF-8?q?test:=20sendRequestConfigSchema=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20assertion=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - decode 결과를 toContain 대신 getTime() 동치 비교로 시간값 정확성 검증 - encode round-trip에서 원본 Date 값과 동치 비교 추가 - showMessageList optional 필드 커버리지 추가 - 빈 문자열 scheduledDate 실패 케이스 추가 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../requests/messages/sendMessage.test.ts | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/test/models/requests/messages/sendMessage.test.ts b/test/models/requests/messages/sendMessage.test.ts index cc816dba..67379878 100644 --- a/test/models/requests/messages/sendMessage.test.ts +++ b/test/models/requests/messages/sendMessage.test.ts @@ -538,24 +538,27 @@ describe('Effect Schema Integration Tests', () => { }); describe('sendRequestConfigSchema', () => { - it('should decode scheduledDate from Date to ISO string', () => { + it('should decode scheduledDate from Date to ISO string preserving time', () => { const futureDate = new Date('2025-06-15T10:30:00.000Z'); const result = Schema.decodeUnknownSync(sendRequestConfigSchema)({ scheduledDate: futureDate, }); expect(typeof result.scheduledDate).toBe('string'); - expect(result.scheduledDate).toContain('2025-06-15'); + expect(new Date(result.scheduledDate!).getTime()).toBe( + futureDate.getTime(), + ); }); - it('should decode scheduledDate from string to ISO string', () => { + it('should decode scheduledDate from string to ISO string preserving time', () => { const dateString = '2025-06-15T10:30:00.000Z'; + const inputDate = new Date(dateString); const result = Schema.decodeUnknownSync(sendRequestConfigSchema)({ scheduledDate: dateString, }); expect(typeof result.scheduledDate).toBe('string'); - expect(result.scheduledDate).toContain('2025-06-15'); + expect(new Date(result.scheduledDate!).getTime()).toBe(inputDate.getTime()); }); it('should fail for invalid scheduledDate string', () => { @@ -566,23 +569,35 @@ describe('sendRequestConfigSchema', () => { expect(Either.isLeft(result)).toBe(true); }); - it('should decode without scheduledDate', () => { + it('should fail for empty string scheduledDate', () => { + const result = Schema.decodeUnknownEither(sendRequestConfigSchema)({ + scheduledDate: '', + }); + + expect(Either.isLeft(result)).toBe(true); + }); + + it('should decode all optional fields correctly', () => { const result = Schema.decodeUnknownSync(sendRequestConfigSchema)({ allowDuplicates: true, appId: 'test-app', + showMessageList: true, }); expect(result.scheduledDate).toBeUndefined(); expect(result.allowDuplicates).toBe(true); expect(result.appId).toBe('test-app'); + expect(result.showMessageList).toBe(true); }); - it('should encode scheduledDate string back to Date', () => { + it('should encode scheduledDate back to original Date value', () => { + const originalDate = new Date('2025-06-15T10:30:00.000Z'); const decoded = Schema.decodeUnknownSync(sendRequestConfigSchema)({ - scheduledDate: new Date('2025-06-15T10:30:00.000Z'), + scheduledDate: originalDate, }); const encoded = Schema.encodeSync(sendRequestConfigSchema)(decoded); expect(encoded.scheduledDate).toBeInstanceOf(Date); + expect(encoded.scheduledDate!.getTime()).toBe(originalDate.getTime()); }); }); From 52e5e5e09a7a3630b42f07153311f41c19f18a7b Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Wed, 15 Apr 2026 10:16:35 +0900 Subject: [PATCH 20/24] =?UTF-8?q?test:=20encode=20round-trip=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20assertion=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getTime() 동치 비교가 타입과 값을 모두 증명하므로 중복된 toBeInstanceOf(Date) assertion 제거. Co-Authored-By: Claude Opus 4.6 (1M context) --- test/models/requests/messages/sendMessage.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/models/requests/messages/sendMessage.test.ts b/test/models/requests/messages/sendMessage.test.ts index 67379878..65facef1 100644 --- a/test/models/requests/messages/sendMessage.test.ts +++ b/test/models/requests/messages/sendMessage.test.ts @@ -597,7 +597,6 @@ describe('sendRequestConfigSchema', () => { }); const encoded = Schema.encodeSync(sendRequestConfigSchema)(decoded); - expect(encoded.scheduledDate).toBeInstanceOf(Date); expect(encoded.scheduledDate!.getTime()).toBe(originalDate.getTime()); }); }); From de464e422deb0e0a1e5f4491daa0d25e52e85077 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Wed, 15 Apr 2026 11:49:10 +0900 Subject: [PATCH 21/24] =?UTF-8?q?refactor:=20dead=20code=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20=EB=B0=8F=20=ED=83=80=EC=9E=85=20=EC=96=B4=EC=84=A4?= =?UTF-8?q?=EC=85=98=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 미사용 defaultMessageRequestSchema 정의 및 barrel export 제거 - stringifyQuery에서 early return으로 as Record 어설션 제거 - defaultFetcher에서 JSON.parse의 any 전파 차단 (unknown 경유 캐스팅) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/defaultFetcher.ts | 7 ++++-- src/lib/stringifyQuery.ts | 22 ++++++++----------- src/models/requests/index.ts | 1 - src/models/requests/messages/requestConfig.ts | 5 ----- 4 files changed, 14 insertions(+), 21 deletions(-) diff --git a/src/lib/defaultFetcher.ts b/src/lib/defaultFetcher.ts index 8fba3c6d..088cc253 100644 --- a/src/lib/defaultFetcher.ts +++ b/src/lib/defaultFetcher.ts @@ -37,14 +37,17 @@ const handleOkResponse = (res: Response) => Effect.flatMap(responseText => { if (!responseText) { if (res.status === 204) { - return Effect.succeed({} as R); + return Effect.succeed({} as unknown as R); } return Effect.fail( makeParseError(res, 'API returned empty response body'), ); } return Effect.try({ - try: () => JSON.parse(responseText) as R, + try: (): R => { + const parsed: unknown = JSON.parse(responseText); + return parsed as R; + }, catch: e => makeParseError(res, toMessage(e)), }); }), diff --git a/src/lib/stringifyQuery.ts b/src/lib/stringifyQuery.ts index c719b4ff..3ee53f0e 100644 --- a/src/lib/stringifyQuery.ts +++ b/src/lib/stringifyQuery.ts @@ -52,21 +52,17 @@ export default function stringifyQuery( `${encodeURIComponent(key)}[${index}]=${encodeURIComponent(String(item))}`, ); } - if (value !== null && value !== undefined) { - if (typeof value === 'object') { - const nested: string[] = []; - for (const [subKey, subValue] of Object.entries( - value as Record, - )) { - nested.push(...processValue(`${key}[${subKey}]`, subValue)); - } - return nested; + if (value === null || value === undefined) { + return []; + } + if (typeof value === 'object') { + const nested: string[] = []; + for (const [subKey, subValue] of Object.entries(value)) { + nested.push(...processValue(`${key}[${subKey}]`, subValue)); } - return [ - `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`, - ]; + return nested; } - return []; + return [`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`]; }; const pairs: string[] = []; diff --git a/src/models/requests/index.ts b/src/models/requests/index.ts index 5b3ebcdb..7367bbfe 100644 --- a/src/models/requests/index.ts +++ b/src/models/requests/index.ts @@ -89,7 +89,6 @@ export { export { type DefaultAgentType, defaultAgentTypeSchema, - defaultMessageRequestSchema, osPlatform, type SendRequestConfigSchema, sdkVersion, diff --git a/src/models/requests/messages/requestConfig.ts b/src/models/requests/messages/requestConfig.ts index a3c2df51..38afbc4e 100644 --- a/src/models/requests/messages/requestConfig.ts +++ b/src/models/requests/messages/requestConfig.ts @@ -45,8 +45,3 @@ export const sendRequestConfigSchema = Schema.Struct({ export type SendRequestConfigSchema = Schema.Schema.Type< typeof sendRequestConfigSchema >; - -export const defaultMessageRequestSchema = Schema.Struct({ - allowDuplicates: Schema.optional(Schema.Boolean), - agent: Schema.optional(defaultAgentTypeSchema), -}); From e8d5e9cd3c83520aff5299889bb67d615bbc402c Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Wed, 15 Apr 2026 12:01:26 +0900 Subject: [PATCH 22/24] fix: restore default message schema export --- src/models/requests/index.ts | 1 + src/models/requests/messages/requestConfig.ts | 5 +++++ test/publicExports.test.ts | 20 +++++++++++++++++++ 3 files changed, 26 insertions(+) create mode 100644 test/publicExports.test.ts diff --git a/src/models/requests/index.ts b/src/models/requests/index.ts index 7367bbfe..5b3ebcdb 100644 --- a/src/models/requests/index.ts +++ b/src/models/requests/index.ts @@ -89,6 +89,7 @@ export { export { type DefaultAgentType, defaultAgentTypeSchema, + defaultMessageRequestSchema, osPlatform, type SendRequestConfigSchema, sdkVersion, diff --git a/src/models/requests/messages/requestConfig.ts b/src/models/requests/messages/requestConfig.ts index 38afbc4e..a3c2df51 100644 --- a/src/models/requests/messages/requestConfig.ts +++ b/src/models/requests/messages/requestConfig.ts @@ -45,3 +45,8 @@ export const sendRequestConfigSchema = Schema.Struct({ export type SendRequestConfigSchema = Schema.Schema.Type< typeof sendRequestConfigSchema >; + +export const defaultMessageRequestSchema = Schema.Struct({ + allowDuplicates: Schema.optional(Schema.Boolean), + agent: Schema.optional(defaultAgentTypeSchema), +}); diff --git a/test/publicExports.test.ts b/test/publicExports.test.ts new file mode 100644 index 00000000..2aeabb23 --- /dev/null +++ b/test/publicExports.test.ts @@ -0,0 +1,20 @@ +import {Schema} from 'effect'; +import {describe, expect, it} from 'vitest'; +import { + defaultMessageRequestSchema, + osPlatform, + sdkVersion, +} from '../src/index'; + +describe('public exports', () => { + it('should keep defaultMessageRequestSchema available from the root entry point', () => { + const decoded = Schema.decodeUnknownSync(defaultMessageRequestSchema)({ + allowDuplicates: true, + agent: {}, + }); + + expect(decoded.allowDuplicates).toBe(true); + expect(decoded.agent?.sdkVersion).toBe(sdkVersion); + expect(decoded.agent?.osPlatform).toBe(osPlatform); + }); +}); From ca32d794623a70b473c5933b83d2a19ab0e92a1e Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Wed, 15 Apr 2026 12:47:57 +0900 Subject: [PATCH 23/24] chore(ci): harden supply-chain workflows --- .github/dependabot.yml | 22 ++++ .github/scripts/wait-for-workflow.sh | 52 ++++++++++ .github/workflows/build-docs.yaml | 54 +++++----- .github/workflows/ci.yml | 91 +++++++++++++++-- .github/workflows/release-please-beta.yml | 116 ++++++++++++++-------- .github/workflows/release-please.yml | 104 ++++++++++++------- .github/workflows/security.yml | 58 +++++++++++ 7 files changed, 388 insertions(+), 109 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/scripts/wait-for-workflow.sh create mode 100644 .github/workflows/security.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..63dc0d86 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,22 @@ +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + cooldown: + default-days: 7 + groups: + production-dependencies: + dependency-type: "production" + development-dependencies: + dependency-type: "development" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + cooldown: + default-days: 7 diff --git a/.github/scripts/wait-for-workflow.sh b/.github/scripts/wait-for-workflow.sh new file mode 100644 index 00000000..410f1261 --- /dev/null +++ b/.github/scripts/wait-for-workflow.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash + +set -euo pipefail + +workflow_file="${1:?workflow file is required}" +branch_name="${2:?branch name is required}" +commit_sha="${3:?commit sha is required}" +poll_seconds="${4:-10}" +max_attempts="${5:-60}" + +echo "Waiting for ${workflow_file} on ${branch_name}@${commit_sha}" + +for attempt in $(seq 1 "${max_attempts}"); do + run_json="$( + gh run list \ + --workflow "${workflow_file}" \ + --branch "${branch_name}" \ + --commit "${commit_sha}" \ + --event push \ + --limit 20 \ + --json databaseId,headSha,status,conclusion \ + --jq 'map(select(.headSha == "'"${commit_sha}"'")) | first' + )" + + if [[ -z "${run_json}" || "${run_json}" == "null" ]]; then + echo "Attempt ${attempt}/${max_attempts}: workflow run not found yet." + sleep "${poll_seconds}" + continue + fi + + run_id="$(jq -r '.databaseId' <<<"${run_json}")" + run_status="$(jq -r '.status' <<<"${run_json}")" + run_conclusion="$(jq -r '.conclusion // empty' <<<"${run_json}")" + + echo "Attempt ${attempt}/${max_attempts}: run=${run_id} status=${run_status} conclusion=${run_conclusion:-pending}" + + if [[ "${run_status}" != "completed" ]]; then + sleep "${poll_seconds}" + continue + fi + + if [[ "${run_conclusion}" == "success" ]]; then + echo "${workflow_file} succeeded for ${commit_sha}" + exit 0 + fi + + echo "${workflow_file} concluded with ${run_conclusion} for ${commit_sha}" + exit 1 +done + +echo "Timed out while waiting for ${workflow_file} on ${commit_sha}" +exit 1 diff --git a/.github/workflows/build-docs.yaml b/.github/workflows/build-docs.yaml index d7dd4309..a2f5395f 100644 --- a/.github/workflows/build-docs.yaml +++ b/.github/workflows/build-docs.yaml @@ -5,10 +5,7 @@ on: types: [ published ] workflow_dispatch: -permissions: - contents: read - pages: write - id-token: write +permissions: {} concurrency: group: pages @@ -18,27 +15,31 @@ jobs: build-docs: if: ${{ !github.event.release.prerelease }} runs-on: ubuntu-latest + permissions: + contents: read steps: - - name: Checkout - uses: actions/checkout@v6 - - name: Setup pnpm - uses: pnpm/action-setup@v5 - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 18 - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - - name: Setup Pages - uses: actions/configure-pages@v6 - - name: Install dependencies - run: pnpm install --frozen-lockfile - - name: Build docs - run: pnpm run docs - - name: Upload Pages artifact - uses: actions/upload-pages-artifact@v4 - with: - path: docs + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + - name: Setup pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 + with: + run_install: false + - name: Setup Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + with: + node-version: 18 + - name: Setup Pages + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6 + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Build docs + run: pnpm run docs + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4 + with: + path: docs deploy: if: ${{ !github.event.release.prerelease }} @@ -47,7 +48,10 @@ jobs: url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest needs: build-docs + permissions: + pages: write + id-token: write steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v5 + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0ba1e0d..d20b495a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,8 +7,7 @@ on: branches: [master, beta] workflow_dispatch: -permissions: - contents: read +permissions: {} concurrency: group: ci-${{ github.ref }} @@ -18,21 +17,56 @@ env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: - ci: - name: CI (Node ${{ matrix.node-version }}) + lint: + name: Lint runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + with: + node-version: 18 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm lint:ci + + test-matrix: + name: Test Matrix (Node ${{ matrix.node-version }}) + runs-on: ubuntu-latest + permissions: + contents: read strategy: matrix: node-version: [18, 20, 22] steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false - name: Setup pnpm - uses: pnpm/action-setup@v5 + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 + with: + run_install: false - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: node-version: ${{ matrix.node-version }} cache: pnpm @@ -41,11 +75,48 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Lint - run: pnpm lint:ci - - name: Test (unit only) run: pnpm test:ci + test: + name: Test + if: ${{ always() }} + needs: test-matrix + runs-on: ubuntu-latest + steps: + - name: Verify matrix result + run: | + if [ "${{ needs.test-matrix.result }}" != "success" ]; then + echo "::error::At least one Node compatibility test failed." + exit 1 + fi + + build: + name: Build + needs: [lint, test] + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + with: + node-version: 18 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Build run: pnpm tsup diff --git a/.github/workflows/release-please-beta.yml b/.github/workflows/release-please-beta.yml index d47c7536..c5fd4c20 100644 --- a/.github/workflows/release-please-beta.yml +++ b/.github/workflows/release-please-beta.yml @@ -1,18 +1,11 @@ name: Beta Release on: - workflow_run: - workflows: ["CI"] + push: branches: [beta] - types: [completed] + workflow_dispatch: -permissions: - contents: write - pull-requests: write - issues: write - actions: write - statuses: write - id-token: write +permissions: {} concurrency: group: release-please-beta @@ -22,12 +15,35 @@ env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: + wait-for-ci: + name: Wait for CI + if: ${{ github.event_name == 'push' }} + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Wait for CI workflow to succeed + env: + BRANCH: ${{ github.ref_name }} + COMMIT_SHA: ${{ github.sha }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bash .github/scripts/wait-for-workflow.sh ci.yml "$BRANCH" "$COMMIT_SHA" + release-please: name: Release Please (Beta) - if: >- - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'push' + needs: wait-for-ci + if: ${{ always() && github.ref_name == 'beta' && (github.event_name != 'push' || needs.wait-for-ci.result == 'success') }} runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + issues: write outputs: release_created: ${{ steps.release.outputs.release_created }} tag_name: ${{ steps.release.outputs.tag_name }} @@ -35,7 +51,7 @@ jobs: steps: - name: Release Please id: release - uses: googleapis/release-please-action@v4 + uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4 with: token: ${{ secrets.GITHUB_TOKEN }} target-branch: beta @@ -54,7 +70,7 @@ jobs: - name: Get PR head SHA id: pr-sha - if: ${{ !steps.release.outputs.release_created }} + if: ${{ steps.release.outputs.release_created != 'true' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} @@ -65,8 +81,11 @@ jobs: test-release-pr: name: Test (Beta Release PR) needs: release-please - if: ${{ !needs.release-please.outputs.release_created && needs.release-please.outputs.pr_head_sha != '' }} + if: ${{ needs.release-please.result == 'success' && needs.release-please.outputs.release_created != 'true' && needs.release-please.outputs.pr_head_sha != '' }} runs-on: ubuntu-latest + permissions: + contents: read + statuses: write steps: - name: Set pending status env: @@ -75,23 +94,24 @@ jobs: REPO: ${{ github.repository }} run: | gh api "repos/$REPO/statuses/$SHA" \ - -f state=pending -f context="Test (Beta)" -f description="Running tests..." \ + -f state=pending -f context="Test" -f description="Running tests..." \ || echo "::warning::Failed to set pending status on $SHA" - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: ${{ needs.release-please.outputs.pr_head_sha }} + persist-credentials: false - name: Setup pnpm - uses: pnpm/action-setup@v5 + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 + with: + run_install: false - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: node-version: 18 - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - name: Install dependencies run: pnpm install --frozen-lockfile @@ -111,7 +131,7 @@ jobs: REPO: ${{ github.repository }} run: | gh api "repos/$REPO/statuses/$SHA" \ - -f state=success -f context="Test (Beta)" -f description="Tests passed" \ + -f state=success -f context="Test" -f description="Tests passed" \ || echo "::warning::Failed to report success status on $SHA" - name: Report failure @@ -123,14 +143,17 @@ jobs: REPO: ${{ github.repository }} run: | gh api "repos/$REPO/statuses/$SHA" \ - -f state=failure -f context="Test (Beta)" -f description="Tests failed" \ + -f state=failure -f context="Test" -f description="Tests failed" \ || echo "::warning::Failed to report failure status on $SHA" lint-release-pr: name: Lint (Beta Release PR) needs: release-please - if: ${{ !needs.release-please.outputs.release_created && needs.release-please.outputs.pr_head_sha != '' }} + if: ${{ needs.release-please.result == 'success' && needs.release-please.outputs.release_created != 'true' && needs.release-please.outputs.pr_head_sha != '' }} runs-on: ubuntu-latest + permissions: + contents: read + statuses: write steps: - name: Set pending status env: @@ -139,23 +162,24 @@ jobs: REPO: ${{ github.repository }} run: | gh api "repos/$REPO/statuses/$SHA" \ - -f state=pending -f context="Lint (Beta)" -f description="Running lint..." \ + -f state=pending -f context="Lint" -f description="Running lint..." \ || echo "::warning::Failed to set pending status on $SHA" - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: ${{ needs.release-please.outputs.pr_head_sha }} + persist-credentials: false - name: Setup pnpm - uses: pnpm/action-setup@v5 + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 + with: + run_install: false - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: node-version: 18 - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - name: Install dependencies run: pnpm install --frozen-lockfile @@ -172,7 +196,7 @@ jobs: REPO: ${{ github.repository }} run: | gh api "repos/$REPO/statuses/$SHA" \ - -f state=success -f context="Lint (Beta)" -f description="Lint passed" \ + -f state=success -f context="Lint" -f description="Lint passed" \ || echo "::warning::Failed to report success status on $SHA" - name: Report failure @@ -184,29 +208,36 @@ jobs: REPO: ${{ github.repository }} run: | gh api "repos/$REPO/statuses/$SHA" \ - -f state=failure -f context="Lint (Beta)" -f description="Lint failed" \ + -f state=failure -f context="Lint" -f description="Lint failed" \ || echo "::warning::Failed to report failure status on $SHA" publish: name: Publish to npm (Beta) needs: release-please - if: ${{ needs.release-please.outputs.release_created == 'true' }} + if: ${{ needs.release-please.result == 'success' && needs.release-please.outputs.release_created == 'true' }} runs-on: ubuntu-latest + environment: release + env: + HAS_NPM_TOKEN: ${{ secrets.NPM_TOKEN != '' }} + permissions: + contents: read + id-token: write steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: ${{ needs.release-please.outputs.tag_name }} + persist-credentials: false - name: Setup pnpm - uses: pnpm/action-setup@v5 + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 + with: + run_install: false - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: node-version: 18 - cache: pnpm - cache-dependency-path: pnpm-lock.yaml registry-url: https://registry.npmjs.org - name: Install dependencies @@ -215,7 +246,12 @@ jobs: - name: Build run: pnpm tsup - - name: Publish with provenance (beta) + - name: Publish beta with trusted publishing + if: ${{ env.HAS_NPM_TOKEN != 'true' }} + run: npm publish --provenance --access public --tag beta + + - name: Publish with provenance token fallback (beta) + if: ${{ env.HAS_NPM_TOKEN == 'true' }} run: npm publish --provenance --access public --tag beta env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index ba0a5abe..aafb5b8e 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -1,18 +1,11 @@ name: Release on: - workflow_run: - workflows: ["CI"] + push: branches: [master] - types: [completed] + workflow_dispatch: -permissions: - contents: write - pull-requests: write - issues: write - actions: write - statuses: write - id-token: write +permissions: {} concurrency: group: release-please-master @@ -22,12 +15,35 @@ env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: + wait-for-ci: + name: Wait for CI + if: ${{ github.event_name == 'push' }} + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Wait for CI workflow to succeed + env: + BRANCH: ${{ github.ref_name }} + COMMIT_SHA: ${{ github.sha }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bash .github/scripts/wait-for-workflow.sh ci.yml "$BRANCH" "$COMMIT_SHA" + release-please: name: Release Please - if: >- - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'push' + needs: wait-for-ci + if: ${{ always() && github.ref_name == 'master' && (github.event_name != 'push' || needs.wait-for-ci.result == 'success') }} runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + issues: write outputs: release_created: ${{ steps.release.outputs.release_created }} tag_name: ${{ steps.release.outputs.tag_name }} @@ -35,7 +51,7 @@ jobs: steps: - name: Release Please id: release - uses: googleapis/release-please-action@v4 + uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4 with: token: ${{ secrets.GITHUB_TOKEN }} @@ -51,7 +67,7 @@ jobs: - name: Get PR head SHA id: pr-sha - if: ${{ !steps.release.outputs.release_created }} + if: ${{ steps.release.outputs.release_created != 'true' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} @@ -62,8 +78,11 @@ jobs: test-release-pr: name: Test (Release PR) needs: release-please - if: ${{ !needs.release-please.outputs.release_created && needs.release-please.outputs.pr_head_sha != '' }} + if: ${{ needs.release-please.result == 'success' && needs.release-please.outputs.release_created != 'true' && needs.release-please.outputs.pr_head_sha != '' }} runs-on: ubuntu-latest + permissions: + contents: read + statuses: write steps: - name: Set pending status continue-on-error: true @@ -76,19 +95,20 @@ jobs: -f state=pending -f context="Test" -f description="Running tests..." - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: ${{ needs.release-please.outputs.pr_head_sha }} + persist-credentials: false - name: Setup pnpm - uses: pnpm/action-setup@v5 + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 + with: + run_install: false - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: node-version: 18 - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - name: Install dependencies run: pnpm install --frozen-lockfile @@ -122,8 +142,11 @@ jobs: lint-release-pr: name: Lint (Release PR) needs: release-please - if: ${{ !needs.release-please.outputs.release_created && needs.release-please.outputs.pr_head_sha != '' }} + if: ${{ needs.release-please.result == 'success' && needs.release-please.outputs.release_created != 'true' && needs.release-please.outputs.pr_head_sha != '' }} runs-on: ubuntu-latest + permissions: + contents: read + statuses: write steps: - name: Set pending status continue-on-error: true @@ -136,19 +159,20 @@ jobs: -f state=pending -f context="Lint" -f description="Running lint..." - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: ${{ needs.release-please.outputs.pr_head_sha }} + persist-credentials: false - name: Setup pnpm - uses: pnpm/action-setup@v5 + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 + with: + run_install: false - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: node-version: 18 - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - name: Install dependencies run: pnpm install --frozen-lockfile @@ -179,23 +203,30 @@ jobs: publish: name: Publish to npm needs: release-please - if: ${{ needs.release-please.outputs.release_created == 'true' }} + if: ${{ needs.release-please.result == 'success' && needs.release-please.outputs.release_created == 'true' }} runs-on: ubuntu-latest + environment: release + env: + HAS_NPM_TOKEN: ${{ secrets.NPM_TOKEN != '' }} + permissions: + contents: read + id-token: write steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: ${{ needs.release-please.outputs.tag_name }} + persist-credentials: false - name: Setup pnpm - uses: pnpm/action-setup@v5 + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 + with: + run_install: false - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: node-version: 18 - cache: pnpm - cache-dependency-path: pnpm-lock.yaml registry-url: https://registry.npmjs.org - name: Install dependencies @@ -204,7 +235,12 @@ jobs: - name: Build run: pnpm tsup - - name: Publish with provenance + - name: Publish with trusted publishing + if: ${{ env.HAS_NPM_TOKEN != 'true' }} + run: npm publish --provenance --access public + + - name: Publish with provenance token fallback + if: ${{ env.HAS_NPM_TOKEN == 'true' }} run: npm publish --provenance --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 00000000..bde6d0cf --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,58 @@ +name: GitHub Actions Security + +on: + push: + branches: [master, beta] + paths: + - ".github/workflows/**" + - ".github/actions/**" + - ".github/scripts/**" + - ".github/dependabot.yml" + - "package.json" + - "pnpm-lock.yaml" + pull_request: + branches: [master, beta] + paths: + - ".github/workflows/**" + - ".github/actions/**" + - ".github/scripts/**" + - ".github/dependabot.yml" + - "package.json" + - "pnpm-lock.yaml" + workflow_dispatch: + +permissions: {} + +jobs: + dependency-review: + name: Dependency review + if: ${{ github.event_name == 'pull_request' }} + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Review dependency changes + uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0 + + zizmor: + name: zizmor + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Audit GitHub Actions + uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3 + with: + advanced-security: false + annotations: true From 22047d377f792f18eaee7812f8e67ca414b7b77c Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Wed, 15 Apr 2026 13:06:06 +0900 Subject: [PATCH 24/24] fix(ci): use release-please component branch names --- .github/workflows/release-please.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index aafb5b8e..4d0795b3 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -72,7 +72,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} run: | - SHA=$(gh pr list --repo "$REPO" --head release-please--branches--master --state open --json headRefOid --jq '.[0].headRefOid // empty') + SHA=$(gh pr list --repo "$REPO" --head release-please--branches--master--components--solapi --state open --json headRefOid --jq '.[0].headRefOid // empty') echo "sha=${SHA:-}" >> "$GITHUB_OUTPUT" test-release-pr: