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`로 원본 접근 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. - 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: diff --git a/AGENTS.md b/AGENTS.md index 92db97a5..d2c7c410 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 @@ -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/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/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 9604a0e0..d490410f 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,14 @@ URL: ${this.url} Response: ${this.responseBody?.substring(0, 500) ?? '(empty)'}`; } } + +export const isErrorResponse = (value: unknown): value is ErrorResponse => { + if (value == null || typeof value !== 'object') return false; + if (!('errorCode' in value) || !('errorMessage' in value)) return false; + return ( + typeof value.errorCode === 'string' && + value.errorCode !== '' && + typeof value.errorMessage === 'string' && + value.errorMessage !== '' + ); +}; diff --git a/src/index.ts b/src/index.ts index a189aa77..d5070ca5 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,13 +7,8 @@ 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) export * from './models/index'; -// Common Types & Schemas export * from './types/index'; /** @@ -25,22 +19,12 @@ 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 위임 /** * 잔액조회 * @returns GetBalanceResponse */ readonly getBalance: typeof CashService.prototype.getBalance; - // IamService 위임 /** * 080 수신 거부 조회 * @param data 080 수신 거부 상세 조회용 request 데이터 @@ -62,7 +46,6 @@ export class SolapiMessageService { */ readonly getBlockNumbers: typeof IamService.prototype.getBlockNumbers; - // KakaoChannelService 위임 /** * 카카오 채널 카테고리 조회 */ @@ -97,7 +80,6 @@ export class SolapiMessageService { */ readonly removeKakaoChannel: typeof KakaoChannelService.prototype.removeKakaoChannel; - // KakaoTemplateService 위임 /** * 카카오 템플릿 카테고리 조회 */ @@ -148,7 +130,6 @@ export class SolapiMessageService { */ readonly removeKakaoAlimtalkTemplate: typeof KakaoTemplateService.prototype.removeKakaoAlimtalkTemplate; - // GroupService 위임 /** * 그룹 생성 * @param allowDuplicates 생성할 그룹이 중복 수신번호를 허용하는지 여부를 확인합니다. @@ -217,15 +198,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건까지 발송할 수 있습니다. @@ -249,7 +221,6 @@ export class SolapiMessageService { */ readonly getStatistics: typeof MessageService.prototype.getStatistics; - // StorageService 위임 /** * 파일(이미지) 업로드 * 카카오 친구톡 이미지는 500kb, MMS는 200kb, 발신번호 서류 인증용 파일은 2mb의 제한이 있음 @@ -267,43 +238,80 @@ 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); + + this.getBalance = cashService.getBalance.bind(cashService); + + this.getBlacks = iamService.getBlacks.bind(iamService); + this.getBlockGroups = iamService.getBlockGroups.bind(iamService); + this.getBlockNumbers = iamService.getBlockNumbers.bind(iamService); + + 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); + + 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; - } - } + 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); + + this.send = messageService.send.bind(messageService); + this.getMessages = messageService.getMessages.bind(messageService); + this.getStatistics = messageService.getStatistics.bind(messageService); + + this.uploadFile = storageService.uploadFile.bind(storageService); } } diff --git a/src/lib/defaultFetcher.ts b/src/lib/defaultFetcher.ts index 683d484d..088cc253 100644 --- a/src/lib/defaultFetcher.ts +++ b/src/lib/defaultFetcher.ts @@ -3,71 +3,123 @@ import { ApiKeyError, ClientError, DefaultError, - ErrorResponse, + isErrorResponse, NetworkError, ServerError, } from '../errors/defaultError'; import getAuthInfo, {AuthenticationParameter} from './authenticator'; -import {runSafePromise} from './effectErrorHandler'; type DefaultRequest = { url: string; method: string; }; -// Effect Data 타입으로 RetryableError 정의 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) => - Effect.tryPromise({ - try: async (): Promise => { - const responseText = await res.text(); + pipe( + Effect.tryPromise({ + try: () => res.text(), + catch: e => makeParseError(res, toMessage(e)), + }), + Effect.flatMap(responseText => { if (!responseText) { if (res.status === 204) { - return {} as R; + return Effect.succeed({} as unknown as R); } - throw new Error('API returned empty response body'); + return Effect.fail( + makeParseError(res, 'API returned empty response body'), + ); } - 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: (): R => { + const parsed: unknown = JSON.parse(responseText); + return parsed as R; }, - }), - }); + catch: e => makeParseError(res, toMessage(e)), + }); + }), + ); const handleClientErrorResponse = (res: Response) => pipe( Effect.tryPromise({ - try: () => res.json() as Promise, - catch: e => - new DefaultError({ - errorCode: 'ParseError', - errorMessage: e instanceof Error ? e.message : String(e), - context: { - responseStatus: res.status, - responseUrl: res.url, - }, + try: () => res.text(), + catch: e => makeParseError(res, toMessage(e)), + }), + 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, + }); + + return Effect.flatMap( + Effect.try({ + try: () => JSON.parse(text) as unknown, + catch: (e: unknown) => + e instanceof SyntaxError + ? genericError + : new ClientError({ + errorCode: 'ResponseParseError', + errorMessage: toMessage(e), + httpStatus: res.status, + url: res.url, + }), }), + json => + Effect.fail( + isErrorResponse(json) + ? new ClientError({ + errorCode: json.errorCode, + errorMessage: json.errorMessage, + httpStatus: res.status, + url: res.url, + }) + : genericError, + ), + ); }), - Effect.flatMap(error => + ); + +/** + * JSON 파싱을 시도하여 적절한 ServerError로 실패하는 Effect를 반환. + * 모든 경로가 ServerError로 실패한다 (서버 에러 응답이므로 성공 경로 없음). + */ +function parseServerErrorBody( + text: string, + genericError: ServerError, + makeError: (errorCode: string, errorMessage: string) => ServerError, +): Effect.Effect { + return Effect.flatMap( + Effect.try({ + try: () => JSON.parse(text) as unknown, + catch: (e: unknown) => + e instanceof SyntaxError + ? genericError + : makeError('ResponseParseError', toMessage(e)), + }), + json => Effect.fail( - new ClientError({ - errorCode: error.errorCode, - errorMessage: error.errorMessage, - httpStatus: res.status, - url: res.url, - }), + isErrorResponse(json) + ? makeError(json.errorCode, json.errorMessage) + : genericError, ), - ), ); +} const handleServerErrorResponse = (res: Response) => pipe( @@ -76,58 +128,30 @@ 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 => { const isProduction = process.env.NODE_ENV === 'production'; - - // 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( + const makeError = ( + errorCode: string, + errorMessage: string, + ): ServerError => new ServerError({ - errorCode: `HTTP_${res.status}`, - errorMessage: text.substring(0, 200) || 'Server error occurred', + errorCode, + errorMessage, httpStatus: res.status, url: res.url, responseBody: isProduction ? undefined : text, - }), + }); + + const genericError = makeError( + `HTTP_${res.status}`, + text.substring(0, 200) || 'Server error occurred', ); + + return parseServerErrorBody(text, genericError, makeError); }), ); @@ -150,10 +174,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}, }), }); @@ -241,20 +263,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..f7a83362 100644 --- a/src/lib/effectErrorHandler.ts +++ b/src/lib/effectErrorHandler.ts @@ -4,16 +4,23 @@ 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 && typeof defect === 'object' && '_tag' in defect) { - const tag = (defect as {_tag: string})._tag; - const message = - 'message' in defect ? String((defect as {message: unknown}).message) : ''; + 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)}`, @@ -79,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 58832b7e..af9a7f7f 100644 --- a/src/lib/fileToBase64.ts +++ b/src/lib/fileToBase64.ts @@ -2,9 +2,7 @@ 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 => { try { const url = new URL(value); @@ -14,7 +12,6 @@ const isHttpUrl = (value: string): boolean => { } }; -// URL → Base64 변환 const fromUrl = (url: string) => Effect.flatMap( Effect.tryPromise({ @@ -50,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), @@ -71,14 +67,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/lib/schemaUtils.ts b/src/lib/schemaUtils.ts index 0d1c958f..0fccd7f6 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 헬퍼. @@ -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/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/lib/stringifyQuery.ts b/src/lib/stringifyQuery.ts index dc1e6245..3ee53f0e 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) { @@ -54,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[] = []; @@ -79,8 +73,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/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/base/kakao/kakaoOption.ts b/src/models/base/kakao/kakaoOption.ts index 2060a9b8..e87de302 100644 --- a/src/models/base/kakao/kakaoOption.ts +++ b/src/models/base/kakao/kakaoOption.ts @@ -1,6 +1,11 @@ -import {runSafeSync} from '@lib/effectErrorHandler'; -import {Data, Effect, Array as EffectArray, pipe, Schema} from 'effect'; -import {type KakaoOptionRequest} from '../../requests/kakao/kakaoOptionRequest'; +import { + Data, + Effect, + Array as EffectArray, + ParseResult, + pipe, + Schema, +} from 'effect'; import { bmsButtonSchema, bmsCarouselCommerceSchema, @@ -11,9 +16,8 @@ import { bmsSubWideItemSchema, bmsVideoSchema, } from './bms'; -import {KakaoButton, kakaoButtonSchema} from './kakaoButton'; +import {kakaoButtonSchema} from './kakaoButton'; -// Effect Data 타입을 활용한 에러 클래스 export class VariableValidationError extends Data.TaggedError( 'VariableValidationError', )<{ @@ -54,7 +58,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'], @@ -108,9 +115,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) { @@ -141,18 +147,15 @@ export type KakaoOptionBmsSchema = Schema.Schema.Type< typeof kakaoOptionBmsSchema >; -// Constants for variable validation 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> => @@ -166,7 +169,6 @@ export const validateVariableNames = ( : Effect.succeed(variables), ); -// Optimized transformation function using Effect pipeline export const transformVariables = ( variables: Record, ): Effect.Effect, VariableValidationError> => @@ -188,14 +190,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), }, ), ), @@ -206,23 +210,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..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, @@ -62,11 +58,10 @@ export { bmsChatBubbleTypeSchema, type KakaoOptionBmsSchema, transformVariables, - type VariableValidationError, + VariableValidationError, validateVariableNames, } from './base/kakao/kakaoOption'; export { - type Message, type MessageSchema, type MessageType, messageSchema, @@ -86,9 +81,11 @@ export { } from './base/rcs/rcsButton'; export { type AdditionalBody, + additionalBodySchema, type RcsOptionRequest, type RcsOptionSchema, rcsOptionRequestSchema, + rcsOptionSchema, } from './base/rcs/rcsOption'; // Requests 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/index.ts b/src/models/requests/index.ts index b47a5cb4..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, @@ -107,8 +103,6 @@ export { type RequestSendOneMessageSchema, requestSendMessageSchema, requestSendOneMessageSchema, - type SingleMessageSendingRequestSchema, - singleMessageSendingRequestSchema, } from './messages/sendMessage'; // Voice export { 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/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/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..a3c2df51 100644 --- a/src/models/requests/messages/requestConfig.ts +++ b/src/models/requests/messages/requestConfig.ts @@ -1,19 +1,16 @@ -import {formatWithTransfer} from '@lib/stringDateTrasnfer'; -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 b53f2815..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,20 +81,13 @@ 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), ); -export const singleMessageSendingRequestSchema = Schema.Struct({ - message: requestSendOneMessageSchema, - agent: agentWithDefaultSchema, -}); - export const multipleMessageSendingRequestSchema = Schema.Struct({ allowDuplicates: Schema.optional(Schema.Boolean), agent: agentWithDefaultSchema, @@ -111,7 +101,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..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, @@ -52,8 +50,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/src/services/defaultService.ts b/src/services/defaultService.ts index 5e60865d..8da9c7b2 100644 --- a/src/services/defaultService.ts +++ b/src/services/defaultService.ts @@ -1,21 +1,26 @@ 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'; -export type RequestConfig = { +type RequestConfig = { method: string; url: string; }; -export type DefaultServiceParameter = { +type DefaultServiceParameter = { httpMethod: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; url: string; body?: T; @@ -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 02781166..0a685854 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, @@ -19,15 +18,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 +33,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건까지 발송할 수 있습니다. @@ -82,23 +49,19 @@ 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]; if (messageParameters.length === 0) { - return yield* Effect.fail( - new BadRequestError({ - message: '데이터가 반드시 1건 이상 기입되어 있어야 합니다.', - }), - ); + return yield* new BadRequestError({ + message: '데이터가 반드시 1건 이상 기입되어 있어야 합니다.', + }); } const decodedConfig = yield* decodeWithBadRequest( @@ -119,7 +82,6 @@ export default class MessageService extends DefaultService { parameterObject, ); - // 3. API 호출 const response = yield* reqEffect< MultipleMessageSendingRequestSchema, DetailGroupMessageResponse @@ -129,19 +91,16 @@ export default class MessageService extends DefaultService { body: parameter, }); - // 4. 모든 메시지 발송건이 실패인 경우 MessageNotReceivedError 반환 const {count} = response.groupInfo; const failedAll = response.failedMessageList.length > 0 && 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; @@ -156,23 +115,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, }), ); } @@ -185,23 +133,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/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'; 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); + }); +}); diff --git a/test/lib/effectErrorHandler.test.ts b/test/lib/effectErrorHandler.test.ts index e36cf8ff..bc280e5f 100644 --- a/test/lib/effectErrorHandler.test.ts +++ b/test/lib/effectErrorHandler.test.ts @@ -33,6 +33,30 @@ 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); + } 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', () => { + expect.assertions(2); + 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/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'); diff --git a/test/models/requests/messages/sendMessage.test.ts b/test/models/requests/messages/sendMessage.test.ts index c06263f6..65facef1 100644 --- a/test/models/requests/messages/sendMessage.test.ts +++ b/test/models/requests/messages/sendMessage.test.ts @@ -1,11 +1,11 @@ -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, requestSendMessageSchema, requestSendOneMessageSchema, - singleMessageSendingRequestSchema, } from '@/models/requests/messages/sendMessage'; describe('phoneNumberSchema', () => { @@ -231,64 +231,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 = { @@ -594,3 +536,67 @@ describe('Effect Schema Integration Tests', () => { }); }); }); + +describe('sendRequestConfigSchema', () => { + 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(new Date(result.scheduledDate!).getTime()).toBe( + futureDate.getTime(), + ); + }); + + 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(new Date(result.scheduledDate!).getTime()).toBe(inputDate.getTime()); + }); + + it('should fail for invalid scheduledDate string', () => { + const result = Schema.decodeUnknownEither(sendRequestConfigSchema)({ + scheduledDate: 'not-a-date', + }); + + expect(Either.isLeft(result)).toBe(true); + }); + + 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 back to original Date value', () => { + const originalDate = new Date('2025-06-15T10:30:00.000Z'); + const decoded = Schema.decodeUnknownSync(sendRequestConfigSchema)({ + scheduledDate: originalDate, + }); + const encoded = Schema.encodeSync(sendRequestConfigSchema)(decoded); + + expect(encoded.scheduledDate!.getTime()).toBe(originalDate.getTime()); + }); +}); 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); + }); +}); 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'); + } + }); }); 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" + } + } + ] } }