Skip to content

Commit 7d2979c

Browse files
feat(responses): sync query API schemas and add runtime validation
- getBalance 응답에 lowBalanceAlert, minimumCash, rechargeTo, deposit 등 실서버에 존재하는 필드를 반영해 스키마 확장 - getStatistics, getGroups, getGroup, getBlacks, getBlockGroups, getKakaoChannel, getKakaoAlimtalkTemplate(s) 응답의 드리프트(nullable, 누락 필드, Record→Array 등) 정합화 - MessageTypeRecord에 rcs_itpl/ltpl, fax, voice, bms_* 신규 메시지 타입 추가 - 조회 응답 전용 storedMessageSchema 신설해 발송용 messageSchema와 분리 - DefaultService.requestEffect/getWithQuery에 responseSchema 주입 경로 마련하고, decodeServerResponse 헬퍼 추가로 서버 응답을 런타임 검증해 드리프트 즉시 감지 - 15개 조회 서비스 메서드에 해당 스키마 연결 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6395aa7 commit 7d2979c

15 files changed

Lines changed: 262 additions & 52 deletions

File tree

src/lib/schemaUtils.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import {ParseResult, Schema} from 'effect';
22
import * as Effect from 'effect/Effect';
3-
import {BadRequestError, InvalidDateError} from '../errors/defaultError';
3+
import {
4+
BadRequestError,
5+
InvalidDateError,
6+
ServerError,
7+
} from '../errors/defaultError';
48
import stringDateTransfer, {formatWithTransfer} from './stringDateTransfer';
59

610
/**
@@ -74,3 +78,24 @@ export const safeFinalize = <T>(
7478
message: error instanceof Error ? error.message : String(error),
7579
}),
7680
});
81+
82+
/**
83+
* API 응답 body를 Effect Schema로 런타임 검증하고 실패 시 ServerError로 래핑.
84+
* 서버가 예고 없이 응답 구조를 바꾼 경우 소비자 측에서 조용히 undefined로 터지는 대신
85+
* 스키마 불일치 위치를 즉시 파악할 수 있도록 한다.
86+
*/
87+
export const decodeServerResponse = <A, I>(
88+
schema: Schema.Schema<A, I>,
89+
data: unknown,
90+
context?: {url?: string; httpStatus?: number},
91+
): Effect.Effect<A, ServerError> =>
92+
Effect.mapError(
93+
Schema.decodeUnknown(schema)(data),
94+
err =>
95+
new ServerError({
96+
errorCode: 'ResponseSchemaMismatch',
97+
errorMessage: ParseResult.TreeFormatter.formatErrorSync(err),
98+
httpStatus: context?.httpStatus ?? 200,
99+
url: context?.url,
100+
}),
101+
);

src/models/base/kakao/kakaoChannel.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export const kakaoChannelSchema = Schema.Struct({
2121
channelId: Schema.String,
2222
searchId: Schema.String,
2323
accountId: Schema.String,
24-
phoneNumber: Schema.String,
24+
phoneNumber: Schema.optional(Schema.String),
2525
sharedAccountIds: Schema.Array(Schema.String),
2626
dateCreated: Schema.optional(
2727
Schema.Union(Schema.String, Schema.DateFromSelf),
@@ -40,7 +40,7 @@ export type KakaoChannel = {
4040
channelId: string;
4141
searchId: string;
4242
accountId: string;
43-
phoneNumber: string;
43+
phoneNumber?: string;
4444
sharedAccountIds: ReadonlyArray<string>;
4545
dateCreated?: Date;
4646
dateUpdated?: Date;
@@ -63,6 +63,6 @@ export function decodeKakaoChannel(
6363
sharedAccountIds: data.sharedAccountIds,
6464
dateCreated,
6565
dateUpdated,
66-
};
66+
} satisfies KakaoChannel;
6767
});
6868
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import {Schema} from 'effect';
2+
import {messageTypeSchema} from './message';
3+
4+
/**
5+
* 조회 응답(getMessages/getGroupMessages)에 포함된 메시지 아이템 스키마.
6+
*
7+
* 발송용 messageSchema와 달리 서버가 저장해둔 값을 그대로 반환하므로
8+
* - optional 필드 상당수가 null로 내려올 수 있다.
9+
* - kakaoOptions/rcsOptions 등 내부 구조가 발송 요청과 다르다(서버 정규화 포맷).
10+
*
11+
* 따라서 핵심 필드만 선언하고, 나머지는 런타임 통과를 위해 NullishOr/Unknown으로 관대하게 허용한다.
12+
* Schema.Struct는 기본적으로 extra 필드를 무시하므로 신규 필드 추가 시에도 drift 없이 통과한다.
13+
*/
14+
export const storedMessageSchema = Schema.Struct({
15+
messageId: Schema.optional(Schema.String),
16+
type: Schema.NullishOr(messageTypeSchema),
17+
to: Schema.optional(Schema.Union(Schema.String, Schema.Array(Schema.String))),
18+
from: Schema.NullishOr(Schema.String),
19+
text: Schema.NullishOr(Schema.String),
20+
imageId: Schema.NullishOr(Schema.String),
21+
subject: Schema.NullishOr(Schema.String),
22+
country: Schema.NullishOr(Schema.String),
23+
accountId: Schema.optional(Schema.String),
24+
groupId: Schema.optional(Schema.String),
25+
status: Schema.NullishOr(Schema.String),
26+
statusCode: Schema.NullishOr(Schema.String),
27+
reason: Schema.NullishOr(Schema.String),
28+
networkName: Schema.NullishOr(Schema.String),
29+
networkCode: Schema.NullishOr(Schema.String),
30+
customFields: Schema.optional(
31+
Schema.NullishOr(Schema.Record({key: Schema.String, value: Schema.String})),
32+
),
33+
autoTypeDetect: Schema.optional(Schema.Union(Schema.Boolean, Schema.Number)),
34+
replacement: Schema.optional(Schema.Union(Schema.Boolean, Schema.Number)),
35+
resendCount: Schema.optional(Schema.Number),
36+
dateCreated: Schema.optional(Schema.String),
37+
dateUpdated: Schema.optional(Schema.String),
38+
dateProcessed: Schema.NullishOr(Schema.String),
39+
dateReceived: Schema.NullishOr(Schema.String),
40+
dateReported: Schema.NullishOr(Schema.String),
41+
kakaoOptions: Schema.optional(Schema.NullishOr(Schema.Unknown)),
42+
rcsOptions: Schema.optional(Schema.NullishOr(Schema.Unknown)),
43+
naverOptions: Schema.optional(Schema.NullishOr(Schema.Unknown)),
44+
faxOptions: Schema.optional(Schema.NullishOr(Schema.Unknown)),
45+
voiceOptions: Schema.optional(Schema.NullishOr(Schema.Unknown)),
46+
replacements: Schema.optional(Schema.NullishOr(Schema.Unknown)),
47+
log: Schema.optional(Schema.NullishOr(Schema.Unknown)),
48+
queues: Schema.optional(Schema.NullishOr(Schema.Unknown)),
49+
currentQueue: Schema.optional(Schema.NullishOr(Schema.Unknown)),
50+
clusterKey: Schema.NullishOr(Schema.String),
51+
unavailableSenderNumber: Schema.optional(
52+
Schema.Union(Schema.Boolean, Schema.Number),
53+
),
54+
faxPageCount: Schema.optional(Schema.Number),
55+
voiceDuration: Schema.optional(Schema.Number),
56+
voiceReplied: Schema.optional(Schema.Union(Schema.Boolean, Schema.Number)),
57+
_id: Schema.optional(Schema.String),
58+
});
59+
export type StoredMessage = Schema.Schema.Type<typeof storedMessageSchema>;

src/models/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ export {
6565
messageSchema,
6666
messageTypeSchema,
6767
} from './base/messages/message';
68+
export {
69+
type StoredMessage,
70+
storedMessageSchema,
71+
} from './base/messages/storedMessage';
6872
export {
6973
type NaverOptionSchema,
7074
naverOptionSchema,

src/models/responses/iam/getBlacksResponse.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import {blackSchema, handleKeySchema} from '@internal-types/commonTypes';
1+
import {blackSchema} from '@internal-types/commonTypes';
22
import {Schema} from 'effect';
33

44
export const getBlacksResponseSchema = Schema.Struct({
55
startKey: Schema.NullishOr(Schema.String),
66
limit: Schema.Number,
77
nextKey: Schema.NullishOr(Schema.String),
8-
blackList: Schema.Record({key: handleKeySchema, value: blackSchema}),
8+
blackList: Schema.Array(blackSchema),
99
});
1010
export type GetBlacksResponse = Schema.Schema.Type<
1111
typeof getBlacksResponseSchema

src/models/responses/kakao/getKakaoTemplateResponse.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export const getKakaoTemplateResponseSchema = kakaoAlimtalkTemplateSchema.pipe(
99
Schema.extend(
1010
Schema.Struct({
1111
assignType: kakaoAlimtalkTemplateAssignTypeSchema,
12-
accountId: Schema.String,
12+
accountId: Schema.NullishOr(Schema.String),
1313
commentable: Schema.Boolean,
1414
dateCreated: Schema.String,
1515
dateUpdated: Schema.String,

src/models/responses/messageResponses.ts

Lines changed: 54 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,9 @@ import {
66
groupIdSchema,
77
groupSchema,
88
logSchema,
9-
messageTypeRecordSchema,
109
} from '@internal-types/commonTypes';
1110
import {Schema} from 'effect';
12-
import {messageSchema} from '../base/messages/message';
11+
import {storedMessageSchema} from '../base/messages/storedMessage';
1312

1413
export const groupMessageResponseSchema = Schema.Struct({
1514
count: countSchema,
@@ -28,9 +27,9 @@ export const groupMessageResponseSchema = Schema.Struct({
2827
price: Schema.Record({key: Schema.String, value: Schema.Unknown}),
2928
dateCreated: Schema.String,
3029
dateUpdated: Schema.String,
31-
scheduledDate: Schema.optional(Schema.String),
32-
dateSent: Schema.optional(Schema.String),
33-
dateCompleted: Schema.optional(Schema.String),
30+
scheduledDate: Schema.NullishOr(Schema.String),
31+
dateSent: Schema.NullishOr(Schema.String),
32+
dateCompleted: Schema.NullishOr(Schema.String),
3433
});
3534
export type GroupMessageResponse = Schema.Schema.Type<
3635
typeof groupMessageResponseSchema
@@ -62,10 +61,10 @@ export type AddMessageResponse = Schema.Schema.Type<
6261
>;
6362

6463
export const getMessagesResponseSchema = Schema.Struct({
65-
startKey: Schema.NullOr(Schema.String),
66-
nextKey: Schema.NullOr(Schema.String),
64+
startKey: Schema.optional(Schema.NullOr(Schema.String)),
65+
nextKey: Schema.optional(Schema.NullOr(Schema.String)),
6766
limit: Schema.Number,
68-
messageList: Schema.Record({key: Schema.String, value: messageSchema}),
67+
messageList: Schema.Record({key: Schema.String, value: storedMessageSchema}),
6968
});
7069
export type GetMessagesResponse = Schema.Schema.Type<
7170
typeof getMessagesResponseSchema
@@ -108,21 +107,37 @@ const statisticsPeriodResultSchema = Schema.Struct({
108107
rcs_lms: Schema.Number,
109108
rcs_mms: Schema.Number,
110109
rcs_tpl: Schema.Number,
110+
rcs_itpl: Schema.optional(Schema.Number),
111+
rcs_ltpl: Schema.optional(Schema.Number),
112+
fax: Schema.optional(Schema.Number),
113+
voice: Schema.optional(Schema.Number),
114+
bms_text: Schema.optional(Schema.Number),
115+
bms_image: Schema.optional(Schema.Number),
116+
bms_wide: Schema.optional(Schema.Number),
117+
bms_wide_item_list: Schema.optional(Schema.Number),
118+
bms_carousel_feed: Schema.optional(Schema.Number),
119+
bms_premium_video: Schema.optional(Schema.Number),
120+
bms_commerce: Schema.optional(Schema.Number),
121+
bms_carousel_commerce: Schema.optional(Schema.Number),
122+
bms_free: Schema.optional(Schema.Number),
111123
});
112124

113125
const refundSchema = Schema.Struct({
114126
balance: Schema.Number,
115127
point: Schema.Number,
128+
deposit: Schema.optional(Schema.Number),
116129
});
117130

118131
const dayPeriodSchema = Schema.Struct({
119132
_id: Schema.String,
120133
month: Schema.String,
134+
date: Schema.optional(Schema.String),
121135
balance: Schema.Number,
122136
point: Schema.Number,
137+
deposit: Schema.optional(Schema.Number),
123138
statusCode: Schema.Record({
124139
key: Schema.String,
125-
value: messageTypeRecordSchema,
140+
value: Schema.Record({key: Schema.String, value: Schema.Number}),
126141
}),
127142
refund: refundSchema,
128143
total: statisticsPeriodResultSchema,
@@ -135,6 +150,8 @@ const monthPeriodRefundSchema = Schema.Struct({
135150
balanceAvg: Schema.Number,
136151
point: Schema.Number,
137152
pointAvg: Schema.Number,
153+
deposit: Schema.optional(Schema.Number),
154+
depositAvg: Schema.optional(Schema.Number),
138155
});
139156

140157
const monthPeriodSchema = Schema.Struct({
@@ -143,8 +160,10 @@ const monthPeriodSchema = Schema.Struct({
143160
balanceAvg: Schema.Number,
144161
point: Schema.Number,
145162
pointAvg: Schema.Number,
163+
deposit: Schema.optional(Schema.Number),
164+
depositAvg: Schema.optional(Schema.Number),
146165
dayPeriod: Schema.Array(dayPeriodSchema),
147-
refund: monthPeriodRefundSchema,
166+
refund: Schema.optional(monthPeriodRefundSchema),
148167
total: statisticsPeriodResultSchema,
149168
successed: statisticsPeriodResultSchema,
150169
failed: statisticsPeriodResultSchema,
@@ -153,25 +172,44 @@ const monthPeriodSchema = Schema.Struct({
153172
export const getStatisticsResponseSchema = Schema.Struct({
154173
balance: Schema.Number,
155174
point: Schema.Number,
175+
deposit: Schema.optional(Schema.Number),
156176
monthlyBalanceAvg: Schema.Number,
157177
monthlyPointAvg: Schema.Number,
178+
monthlyDepositAvg: Schema.optional(Schema.Number),
158179
monthPeriod: Schema.Array(monthPeriodSchema),
159180
total: statisticsPeriodResultSchema,
160181
successed: statisticsPeriodResultSchema,
161182
failed: statisticsPeriodResultSchema,
162-
dailyBalanceAvg: Schema.Number,
163-
dailyPointAvg: Schema.Number,
164-
dailyTotalCountAvg: Schema.Number,
165-
dailyFailedCountAvg: Schema.Number,
166-
dailySuccessedCountAvg: Schema.Number,
183+
dailyBalanceAvg: Schema.optional(Schema.Number),
184+
dailyPointAvg: Schema.optional(Schema.Number),
185+
dailyTotalCountAvg: Schema.optional(Schema.Number),
186+
dailyFailedCountAvg: Schema.optional(Schema.Number),
187+
dailySuccessedCountAvg: Schema.optional(Schema.Number),
167188
});
168189
export type GetStatisticsResponse = Schema.Schema.Type<
169190
typeof getStatisticsResponseSchema
170191
>;
171192

193+
const lowBalanceAlertSchema = Schema.Struct({
194+
notificationBalance: Schema.String,
195+
currentBalance: Schema.String,
196+
balances: Schema.Array(Schema.Number),
197+
channels: Schema.Array(Schema.String),
198+
enabled: Schema.Boolean,
199+
});
200+
export type LowBalanceAlert = Schema.Schema.Type<typeof lowBalanceAlertSchema>;
201+
172202
export const getBalanceResponseSchema = Schema.Struct({
173-
balance: Schema.Number,
203+
lowBalanceAlert: Schema.optional(lowBalanceAlertSchema),
174204
point: Schema.Number,
205+
minimumCash: Schema.optional(Schema.Number),
206+
rechargeTo: Schema.optional(Schema.Number),
207+
rechargeTryCount: Schema.optional(Schema.Number),
208+
autoRecharge: Schema.optional(Schema.Number),
209+
accountId: Schema.optional(Schema.String),
210+
balance: Schema.Number,
211+
deposit: Schema.optional(Schema.Number),
212+
balanceOnly: Schema.optional(Schema.Number),
175213
});
176214
export type GetBalanceResponse = Schema.Schema.Type<
177215
typeof getBalanceResponseSchema

src/services/cash/cashService.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import {runSafePromise} from '@lib/effectErrorHandler';
2-
import {type GetBalanceResponse} from '@models/responses/messageResponses';
2+
import {
3+
type GetBalanceResponse,
4+
getBalanceResponseSchema,
5+
} from '@models/responses/messageResponses';
36
import DefaultService from '../defaultService';
47

58
export default class CashService extends DefaultService {
@@ -12,6 +15,7 @@ export default class CashService extends DefaultService {
1215
this.requestEffect<never, GetBalanceResponse>({
1316
httpMethod: 'GET',
1417
url: 'cash/v1/balance',
18+
responseSchema: getBalanceResponseSchema,
1519
}),
1620
);
1721
}

0 commit comments

Comments
 (0)