Skip to content

Commit 4e4317b

Browse files
Merge pull request #154 from Palbahngmiyine/sync/response-schema-validation-beta
fix(responses): sync query API schemas and add runtime response validation
2 parents 6395aa7 + 28c912c commit 4e4317b

19 files changed

Lines changed: 701 additions & 75 deletions

src/errors/defaultError.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,37 @@ export class UnhandledExitError extends Data.TaggedError('UnhandledExitError')<{
129129
}
130130
}
131131

132+
/**
133+
* @description 서버가 2xx로 응답했으나 body가 SDK가 기대하는 스키마를 만족하지 못할 때 발생.
134+
* 5xx를 의미하지 않으므로 ServerError와 분리하여 소비자의 재시도/알림 분기가 오염되지 않게 한다.
135+
*/
136+
export class ResponseSchemaMismatchError extends Data.TaggedError(
137+
'ResponseSchemaMismatchError',
138+
)<{
139+
readonly message: string;
140+
readonly url?: string;
141+
readonly validationErrors: ReadonlyArray<string>;
142+
readonly responseBody?: string;
143+
}> {
144+
toString(): string {
145+
const header = `ResponseSchemaMismatchError: ${this.message}`;
146+
const url = this.url ? `\nURL: ${this.url}` : '';
147+
const issues =
148+
this.validationErrors.length > 0
149+
? `\nIssues:\n- ${this.validationErrors.join('\n- ')}`
150+
: '';
151+
// defense-in-depth: 이 클래스는 public이라 외부에서 직접 생성될 수 있으므로,
152+
// creation 시점 정책과 무관하게 redact 환경에서는 responseBody를 출력하지 않는다.
153+
const env = process.env.NODE_ENV?.trim().toLowerCase();
154+
const isVerbose = env === 'development' || env === 'test';
155+
const body =
156+
isVerbose && this.responseBody
157+
? `\nResponse: ${this.responseBody.substring(0, 500)}`
158+
: '';
159+
return `${header}${url}${issues}${body}`;
160+
}
161+
}
162+
132163
// 5xx 서버 에러용
133164
export class ServerError extends Data.TaggedError('ServerError')<{
134165
readonly errorCode: string;

src/lib/schemaUtils.ts

Lines changed: 97 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+
ResponseSchemaMismatchError,
7+
} from '../errors/defaultError';
48
import stringDateTransfer, {formatWithTransfer} from './stringDateTransfer';
59

610
/**
@@ -74,3 +78,95 @@ export const safeFinalize = <T>(
7478
message: error instanceof Error ? error.message : String(error),
7579
}),
7680
});
81+
82+
const stringifyResponseBody = (data: unknown): string | undefined => {
83+
if (data === undefined) return undefined;
84+
if (typeof data === 'string') return data;
85+
try {
86+
return JSON.stringify(data);
87+
} catch (err) {
88+
// circular / BigInt 등 직렬화 실패를 silent 하게 버리지 않고
89+
// 최소한 실패 사유와 타입 태그를 운영 로그에서 확인할 수 있도록 둔다.
90+
const reason = err instanceof Error ? err.message : String(err);
91+
return `[unserializable: ${reason}] ${Object.prototype.toString.call(data)}`;
92+
}
93+
};
94+
95+
/**
96+
* URL에서 PII가 실릴 수 있는 모든 부분(query, fragment, userinfo)을 redact 한다.
97+
* SOLAPI 조회 API는 `to`, `from`, `startDate` 등을 query string에 싣고,
98+
* 소비자가 전달한 URL에 userinfo가 포함될 여지도 있으므로 모두 제거한다.
99+
*/
100+
export const redactUrlForProduction = (
101+
url: string | undefined,
102+
): string | undefined => {
103+
if (!url) return url;
104+
try {
105+
const parsed = new URL(url);
106+
const hadQuery = parsed.search.length > 0;
107+
parsed.search = hadQuery ? '?[redacted]' : '';
108+
parsed.hash = '';
109+
parsed.username = '';
110+
parsed.password = '';
111+
return parsed.toString();
112+
} catch {
113+
// 파싱 불가한 상대/비정상 URL은 보수적으로 첫 구분자 이후 전부 마스킹
114+
const cut = url.search(/[?#;]/);
115+
return cut === -1 ? url : `${url.slice(0, cut)}?[redacted]`;
116+
}
117+
};
118+
119+
/**
120+
* PII 보호 gate는 safe-by-default: 명시적으로 개발자 환경(development/test)일 때만
121+
* 상세 정보를 노출한다. 운영/스테이징/NODE_ENV 미설정 환경은 모두 redact 경로를 탄다 —
122+
* 원본 값이 로그/Sentry 등으로 유출되지 않도록 하기 위함.
123+
*
124+
* NODE_ENV는 `.trim().toLowerCase()`로 정규화해 Windows PowerShell 등에서 흔한
125+
* `Development` 오타를 verbose 모드로 인식하도록 한다.
126+
*/
127+
export const shouldRedactSensitive = (): boolean => {
128+
const env = process.env.NODE_ENV?.trim().toLowerCase();
129+
return env !== 'development' && env !== 'test';
130+
};
131+
132+
/**
133+
* API 응답 body를 Effect Schema로 런타임 검증하고 실패 시 ResponseSchemaMismatchError로 래핑.
134+
* 서버가 예고 없이 응답 구조를 바꾼 경우 소비자 측에서 조용히 undefined로 터지는 대신
135+
* 스키마 불일치 위치(ArrayFormatter issue path)와 원본 responseBody를 함께 보존하여
136+
* 운영 환경에서도 재현 가능하게 한다.
137+
*
138+
* Schema는 requirement 채널을 never로 제한 — 외부 서비스를 요구하는 transform을 금지하여
139+
* 응답 디코딩이 항상 순수하게 끝나도록 강제한다.
140+
*/
141+
export const decodeServerResponse = <A, I>(
142+
schema: Schema.Schema<A, I, never>,
143+
data: unknown,
144+
context?: {url?: string},
145+
): Effect.Effect<A, ResponseSchemaMismatchError> =>
146+
// onExcessProperty: 'preserve' — 서버가 추가로 내려준 미선언 필드를 strip 하지 않는다.
147+
// 부분 스키마로 검증하는 조회 엔드포인트에서 필드 조용히 사라지는 silent data loss를 방지.
148+
Effect.mapError(
149+
Schema.decodeUnknown(schema, {onExcessProperty: 'preserve'})(data),
150+
err => {
151+
// PII 누출을 차단한다 (safe-by-default: development/test 외에는 모두 redact):
152+
// - responseBody: 원본 payload에 전화번호/계정 데이터가 실릴 수 있음
153+
// - validationErrors 메시지: ParseResult 포맷터는 기대치와 함께 *실제 값*을 문자열로 삽입함
154+
// - url: getMessages 등 조회 API는 to/from 등 전화번호를 query string에 실음
155+
// Sentry 등은 toString() 대신 enumerable 필드를 직렬화하므로 creation 단계에서 제거해야 안전.
156+
const redact = shouldRedactSensitive();
157+
const issues = ParseResult.ArrayFormatter.formatErrorSync(err);
158+
return new ResponseSchemaMismatchError({
159+
message: redact
160+
? `Response schema mismatch on ${issues.length} field(s)`
161+
: ParseResult.TreeFormatter.formatErrorSync(err),
162+
validationErrors: issues.map(issue => {
163+
const path = issue.path.length > 0 ? issue.path.join('.') : '(root)';
164+
return redact
165+
? `${path}: [${issue._tag}]`
166+
: `${path}: ${issue.message}`;
167+
}),
168+
url: redact ? redactUrlForProduction(context?.url) : context?.url,
169+
responseBody: redact ? undefined : stringifyResponseBody(data),
170+
});
171+
},
172+
);

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: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import {ParseResult, Schema} from 'effect';
2+
import {messageTypeSchema} from './message';
3+
4+
/**
5+
* 서버가 동일 필드를 boolean 또는 0/1 정수로 섞어 내려주는 경우가 있어
6+
* 소비자에게는 boolean으로만 노출되도록 wire 단계에서 정규화한다.
7+
* 0/1 외의 숫자(NaN, 2, -1 등)는 drift 신호이므로 silent 처리하지 않고
8+
* ResponseSchemaMismatchError로 전파되도록 transformOrFail을 사용한다.
9+
*/
10+
const booleanOrZeroOne = Schema.transformOrFail(
11+
Schema.Union(Schema.Boolean, Schema.Number),
12+
Schema.Boolean,
13+
{
14+
decode: (value, _opts, ast) => {
15+
if (typeof value === 'boolean') return ParseResult.succeed(value);
16+
if (value === 0) return ParseResult.succeed(false);
17+
if (value === 1) return ParseResult.succeed(true);
18+
return ParseResult.fail(
19+
new ParseResult.Type(
20+
ast,
21+
value,
22+
`Expected boolean, 0, or 1 but received ${String(value)}`,
23+
),
24+
);
25+
},
26+
encode: value => ParseResult.succeed(value),
27+
strict: true,
28+
},
29+
);
30+
31+
/**
32+
* 조회 응답(getMessages/getGroupMessages)에 포함된 메시지 아이템 스키마.
33+
*
34+
* 발송용 messageSchema와 달리 서버가 저장해둔 값을 그대로 반환하므로
35+
* - optional 필드 상당수가 null로 내려올 수 있다.
36+
* - kakaoOptions/rcsOptions 등 내부 구조가 발송 요청과 다르다(서버 정규화 포맷).
37+
*
38+
* 핵심 필드만 선언하고 타입 수준에서 검증/정규화한다. 여기에 없는 필드는
39+
* decodeServerResponse의 onExcessProperty:'preserve' 옵션으로 런타임에 그대로 보존된다.
40+
*/
41+
export const storedMessageSchema = Schema.Struct({
42+
messageId: Schema.optional(Schema.String),
43+
type: Schema.NullishOr(messageTypeSchema),
44+
to: Schema.optional(Schema.Union(Schema.String, Schema.Array(Schema.String))),
45+
from: Schema.NullishOr(Schema.String),
46+
text: Schema.NullishOr(Schema.String),
47+
imageId: Schema.NullishOr(Schema.String),
48+
subject: Schema.NullishOr(Schema.String),
49+
country: Schema.NullishOr(Schema.String),
50+
accountId: Schema.optional(Schema.String),
51+
groupId: Schema.optional(Schema.String),
52+
status: Schema.NullishOr(Schema.String),
53+
statusCode: Schema.NullishOr(Schema.String),
54+
reason: Schema.NullishOr(Schema.String),
55+
networkName: Schema.NullishOr(Schema.String),
56+
networkCode: Schema.NullishOr(Schema.String),
57+
customFields: Schema.optional(
58+
Schema.NullishOr(Schema.Record({key: Schema.String, value: Schema.String})),
59+
),
60+
autoTypeDetect: Schema.optional(booleanOrZeroOne),
61+
replacement: Schema.optional(booleanOrZeroOne),
62+
resendCount: Schema.optional(Schema.Number),
63+
dateCreated: Schema.optional(Schema.String),
64+
dateUpdated: Schema.optional(Schema.String),
65+
dateProcessed: Schema.NullishOr(Schema.String),
66+
dateReceived: Schema.NullishOr(Schema.String),
67+
dateReported: Schema.NullishOr(Schema.String),
68+
// 옵션 객체는 서버 정규화 포맷(저장 형태)으로 발송 요청용 스키마와 필드가 다르다.
69+
// 상세 타이핑을 확정하려면 각 옵션별 별도 조회 스키마 정의가 필요하지만 본 PR 범위를
70+
// 벗어나므로, 최소한 "object"임을 보장해 원시 값이 섞이는 drift를 감지할 수 있게 한다.
71+
kakaoOptions: Schema.optional(
72+
Schema.NullishOr(
73+
Schema.Record({key: Schema.String, value: Schema.Unknown}),
74+
),
75+
),
76+
rcsOptions: Schema.optional(
77+
Schema.NullishOr(
78+
Schema.Record({key: Schema.String, value: Schema.Unknown}),
79+
),
80+
),
81+
naverOptions: Schema.optional(
82+
Schema.NullishOr(
83+
Schema.Record({key: Schema.String, value: Schema.Unknown}),
84+
),
85+
),
86+
faxOptions: Schema.optional(
87+
Schema.NullishOr(
88+
Schema.Record({key: Schema.String, value: Schema.Unknown}),
89+
),
90+
),
91+
voiceOptions: Schema.optional(
92+
Schema.NullishOr(
93+
Schema.Record({key: Schema.String, value: Schema.Unknown}),
94+
),
95+
),
96+
replacements: Schema.optional(Schema.NullishOr(Schema.Array(Schema.Unknown))),
97+
log: Schema.optional(Schema.NullishOr(Schema.Array(Schema.Unknown))),
98+
queues: Schema.optional(Schema.NullishOr(Schema.Array(Schema.Unknown))),
99+
currentQueue: Schema.optional(Schema.NullishOr(Schema.Unknown)),
100+
clusterKey: Schema.NullishOr(Schema.String),
101+
unavailableSenderNumber: Schema.optional(Schema.NullishOr(booleanOrZeroOne)),
102+
faxPageCount: Schema.optional(Schema.NullishOr(Schema.Number)),
103+
voiceDuration: Schema.optional(Schema.NullishOr(Schema.Number)),
104+
voiceReplied: Schema.optional(Schema.NullishOr(booleanOrZeroOne)),
105+
_id: Schema.optional(Schema.String),
106+
});
107+
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/getKakaoAlimtalkTemplatesResponse.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import {getKakaoTemplateResponseSchema} from './getKakaoTemplateResponse';
55
export const getKakaoAlimtalkTemplatesResponseSchema = Schema.Struct({
66
limit: Schema.Number,
77
templateList: Schema.Array(getKakaoTemplateResponseSchema),
8-
startKey: Schema.String,
9-
nextKey: Schema.NullOr(Schema.String),
8+
startKey: Schema.NullishOr(Schema.String),
9+
nextKey: Schema.NullishOr(Schema.String),
1010
});
1111
export type GetKakaoAlimtalkTemplatesResponseSchema = Schema.Schema.Type<
1212
typeof getKakaoAlimtalkTemplatesResponseSchema
@@ -17,6 +17,6 @@ export type GetKakaoAlimtalkTemplatesResponse =
1717
export type GetKakaoAlimtalkTemplatesFinalizeResponse = {
1818
limit: number;
1919
templateList: Array<KakaoAlimtalkTemplate>;
20-
startKey: string;
21-
nextKey: string | null;
20+
startKey: string | null | undefined;
21+
nextKey: string | null | undefined;
2222
};

src/models/responses/kakao/getKakaoChannelsResponse.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import {
66

77
export const getKakaoChannelsResponseSchema = Schema.Struct({
88
limit: Schema.Number,
9-
startKey: Schema.String,
10-
nextKey: Schema.NullOr(Schema.String),
9+
startKey: Schema.NullishOr(Schema.String),
10+
nextKey: Schema.NullishOr(Schema.String),
1111
channelList: Schema.Array(kakaoChannelSchema),
1212
});
1313

@@ -17,7 +17,7 @@ export type GetKakaoChannelsResponse = Schema.Schema.Type<
1717

1818
export type GetKakaoChannelsFinalizeResponse = {
1919
limit: number;
20-
startKey: string;
21-
nextKey: string | null;
20+
startKey: string | null | undefined;
21+
nextKey: string | null | undefined;
2222
channelList: Array<KakaoChannel>;
2323
};

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,

0 commit comments

Comments
 (0)