@mandujs/core 0.9.23 → 0.9.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/contract/index.ts +1 -0
- package/src/contract/normalize.test.ts +276 -0
- package/src/contract/normalize.ts +404 -0
- package/src/contract/schema.ts +44 -9
- package/src/contract/validator.ts +232 -1
- package/src/openapi/generator.ts +75 -11
- package/src/runtime/index.ts +4 -3
- package/src/runtime/logger.test.ts +345 -0
- package/src/runtime/logger.ts +677 -0
- package/src/runtime/streaming-ssr.ts +69 -24
package/package.json
CHANGED
package/src/contract/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ export * from "./types";
|
|
|
13
13
|
export * from "./validator";
|
|
14
14
|
export * from "./handler";
|
|
15
15
|
export * from "./client";
|
|
16
|
+
export * from "./normalize";
|
|
16
17
|
|
|
17
18
|
import type { ContractDefinition, ContractInstance, ContractSchema } from "./schema";
|
|
18
19
|
import type { ContractHandlers, RouteDefinition } from "./handler";
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mandu Schema Normalization Tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, test, expect } from "bun:test";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import {
|
|
8
|
+
normalizeData,
|
|
9
|
+
safeNormalizeData,
|
|
10
|
+
normalizeSchema,
|
|
11
|
+
createCoerceSchema,
|
|
12
|
+
normalizeRequestData,
|
|
13
|
+
setNormalizeOptions,
|
|
14
|
+
resetNormalizeOptions,
|
|
15
|
+
getNormalizeOptions,
|
|
16
|
+
} from "./normalize";
|
|
17
|
+
|
|
18
|
+
describe("normalizeData", () => {
|
|
19
|
+
const schema = z.object({
|
|
20
|
+
name: z.string(),
|
|
21
|
+
age: z.number(),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("strip 모드: 정의되지 않은 필드 제거", () => {
|
|
25
|
+
const input = { name: "Kim", age: 25, admin: true, role: "superuser" };
|
|
26
|
+
const result = normalizeData(schema, input, { mode: "strip" });
|
|
27
|
+
|
|
28
|
+
expect(result).toEqual({ name: "Kim", age: 25 });
|
|
29
|
+
expect(result).not.toHaveProperty("admin");
|
|
30
|
+
expect(result).not.toHaveProperty("role");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("strict 모드: 정의되지 않은 필드 있으면 에러", () => {
|
|
34
|
+
const input = { name: "Kim", age: 25, admin: true };
|
|
35
|
+
|
|
36
|
+
expect(() => {
|
|
37
|
+
normalizeData(schema, input, { mode: "strict" });
|
|
38
|
+
}).toThrow();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("passthrough 모드: 모든 필드 허용", () => {
|
|
42
|
+
const input = { name: "Kim", age: 25, admin: true };
|
|
43
|
+
const result = normalizeData(schema, input, { mode: "passthrough" });
|
|
44
|
+
|
|
45
|
+
expect(result).toEqual({ name: "Kim", age: 25, admin: true });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("기본 모드는 strip", () => {
|
|
49
|
+
resetNormalizeOptions();
|
|
50
|
+
const input = { name: "Kim", age: 25, extra: "field" };
|
|
51
|
+
const result = normalizeData(schema, input);
|
|
52
|
+
|
|
53
|
+
expect(result).toEqual({ name: "Kim", age: 25 });
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("safeNormalizeData", () => {
|
|
58
|
+
const schema = z.object({
|
|
59
|
+
name: z.string(),
|
|
60
|
+
age: z.number(),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("성공 시 success: true와 data 반환", () => {
|
|
64
|
+
const input = { name: "Kim", age: 25, extra: true };
|
|
65
|
+
const result = safeNormalizeData(schema, input, { mode: "strip" });
|
|
66
|
+
|
|
67
|
+
expect(result.success).toBe(true);
|
|
68
|
+
if (result.success) {
|
|
69
|
+
expect(result.data).toEqual({ name: "Kim", age: 25 });
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("실패 시 success: false와 error 반환", () => {
|
|
74
|
+
const input = { name: "Kim", age: "not a number" };
|
|
75
|
+
const result = safeNormalizeData(schema, input);
|
|
76
|
+
|
|
77
|
+
expect(result.success).toBe(false);
|
|
78
|
+
if (!result.success) {
|
|
79
|
+
expect(result.error).toBeDefined();
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("createCoerceSchema", () => {
|
|
85
|
+
test("문자열을 숫자로 변환", () => {
|
|
86
|
+
const schema = z.object({
|
|
87
|
+
page: z.number(),
|
|
88
|
+
limit: z.number(),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const coerced = createCoerceSchema(schema);
|
|
92
|
+
const result = coerced.parse({ page: "1", limit: "10" });
|
|
93
|
+
|
|
94
|
+
expect(result).toEqual({ page: 1, limit: 10 });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("문자열을 불리언으로 변환", () => {
|
|
98
|
+
const schema = z.object({
|
|
99
|
+
active: z.boolean(),
|
|
100
|
+
verified: z.boolean(),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const coerced = createCoerceSchema(schema);
|
|
104
|
+
|
|
105
|
+
expect(coerced.parse({ active: "true", verified: "false" })).toEqual({
|
|
106
|
+
active: true,
|
|
107
|
+
verified: false,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
expect(coerced.parse({ active: "1", verified: "0" })).toEqual({
|
|
111
|
+
active: true,
|
|
112
|
+
verified: false,
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("optional 필드 처리", () => {
|
|
117
|
+
const schema = z.object({
|
|
118
|
+
page: z.number().optional(),
|
|
119
|
+
sort: z.string().optional(),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const coerced = createCoerceSchema(schema);
|
|
123
|
+
|
|
124
|
+
expect(coerced.parse({ page: "5" })).toEqual({ page: 5 });
|
|
125
|
+
expect(coerced.parse({})).toEqual({});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("default 값 처리", () => {
|
|
129
|
+
const schema = z.object({
|
|
130
|
+
page: z.number().default(1),
|
|
131
|
+
limit: z.number().default(10),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const coerced = createCoerceSchema(schema);
|
|
135
|
+
|
|
136
|
+
expect(coerced.parse({})).toEqual({ page: 1, limit: 10 });
|
|
137
|
+
expect(coerced.parse({ page: "5" })).toEqual({ page: 5, limit: 10 });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("숫자 제약 조건 유지 (min, max)", () => {
|
|
141
|
+
const schema = z.object({
|
|
142
|
+
page: z.number().min(1).max(100),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const coerced = createCoerceSchema(schema);
|
|
146
|
+
|
|
147
|
+
expect(coerced.parse({ page: "50" })).toEqual({ page: 50 });
|
|
148
|
+
expect(() => coerced.parse({ page: "0" })).toThrow();
|
|
149
|
+
expect(() => coerced.parse({ page: "101" })).toThrow();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("배열 요소 변환", () => {
|
|
153
|
+
const schema = z.object({
|
|
154
|
+
ids: z.array(z.number()),
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const coerced = createCoerceSchema(schema);
|
|
158
|
+
|
|
159
|
+
expect(coerced.parse({ ids: ["1", "2", "3"] })).toEqual({
|
|
160
|
+
ids: [1, 2, 3],
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("normalizeRequestData", () => {
|
|
166
|
+
const schemas = {
|
|
167
|
+
query: z.object({
|
|
168
|
+
page: z.number(),
|
|
169
|
+
limit: z.number(),
|
|
170
|
+
}),
|
|
171
|
+
params: z.object({
|
|
172
|
+
id: z.number(),
|
|
173
|
+
}),
|
|
174
|
+
body: z.object({
|
|
175
|
+
name: z.string(),
|
|
176
|
+
email: z.string(),
|
|
177
|
+
}),
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
test("query: coerce + strip 적용", () => {
|
|
181
|
+
const result = normalizeRequestData(
|
|
182
|
+
schemas,
|
|
183
|
+
{
|
|
184
|
+
query: { page: "1", limit: "10", extra: "field" },
|
|
185
|
+
params: { id: "123" },
|
|
186
|
+
body: { name: "Kim", email: "a@b.c" },
|
|
187
|
+
},
|
|
188
|
+
{ mode: "strip", coerceQueryParams: true }
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
expect(result.query).toEqual({ page: 1, limit: 10 });
|
|
192
|
+
expect(result.params).toEqual({ id: 123 });
|
|
193
|
+
expect(result.body).toEqual({ name: "Kim", email: "a@b.c" });
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("body: strip 적용 (악의적 필드 제거)", () => {
|
|
197
|
+
const result = normalizeRequestData(
|
|
198
|
+
schemas,
|
|
199
|
+
{
|
|
200
|
+
body: {
|
|
201
|
+
name: "Kim",
|
|
202
|
+
email: "a@b.c",
|
|
203
|
+
admin: true,
|
|
204
|
+
role: "superuser",
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
{ mode: "strip" }
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
expect(result.body).toEqual({ name: "Kim", email: "a@b.c" });
|
|
211
|
+
expect(result.body).not.toHaveProperty("admin");
|
|
212
|
+
expect(result.body).not.toHaveProperty("role");
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe("전역 옵션 설정", () => {
|
|
217
|
+
test("setNormalizeOptions로 기본 모드 변경", () => {
|
|
218
|
+
resetNormalizeOptions();
|
|
219
|
+
expect(getNormalizeOptions().mode).toBe("strip");
|
|
220
|
+
|
|
221
|
+
setNormalizeOptions({ mode: "strict" });
|
|
222
|
+
expect(getNormalizeOptions().mode).toBe("strict");
|
|
223
|
+
|
|
224
|
+
resetNormalizeOptions();
|
|
225
|
+
expect(getNormalizeOptions().mode).toBe("strip");
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe("보안 시나리오", () => {
|
|
230
|
+
test("Mass Assignment 공격 방지", () => {
|
|
231
|
+
const UserSchema = z.object({
|
|
232
|
+
name: z.string(),
|
|
233
|
+
email: z.string().email(),
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// 공격자가 admin 필드를 추가해서 보냄
|
|
237
|
+
const attackPayload = {
|
|
238
|
+
name: "Hacker",
|
|
239
|
+
email: "hacker@evil.com",
|
|
240
|
+
isAdmin: true,
|
|
241
|
+
role: "superuser",
|
|
242
|
+
permissions: ["all"],
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const result = normalizeData(UserSchema, attackPayload, { mode: "strip" });
|
|
246
|
+
|
|
247
|
+
// 정의된 필드만 남음
|
|
248
|
+
expect(result).toEqual({
|
|
249
|
+
name: "Hacker",
|
|
250
|
+
email: "hacker@evil.com",
|
|
251
|
+
});
|
|
252
|
+
expect(result).not.toHaveProperty("isAdmin");
|
|
253
|
+
expect(result).not.toHaveProperty("role");
|
|
254
|
+
expect(result).not.toHaveProperty("permissions");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("Prototype Pollution 방지", () => {
|
|
258
|
+
const schema = z.object({
|
|
259
|
+
name: z.string(),
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// __proto__ 필드로 공격 시도
|
|
263
|
+
const attackPayload = {
|
|
264
|
+
name: "Kim",
|
|
265
|
+
__proto__: { polluted: true },
|
|
266
|
+
constructor: { prototype: { hacked: true } },
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const result = normalizeData(schema, attackPayload, { mode: "strip" });
|
|
270
|
+
|
|
271
|
+
expect(result).toEqual({ name: "Kim" });
|
|
272
|
+
// Object.keys()로 실제 own property 확인 (모든 객체는 __proto__ 접근 가능)
|
|
273
|
+
expect(Object.keys(result)).toEqual(["name"]);
|
|
274
|
+
expect(Object.keys(result)).not.toContain("constructor");
|
|
275
|
+
});
|
|
276
|
+
});
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mandu Schema Normalization
|
|
3
|
+
* 스키마 기반 데이터 정규화 (보안 + 타입 안전성)
|
|
4
|
+
*
|
|
5
|
+
* 기능:
|
|
6
|
+
* - Strip: 정의되지 않은 필드 제거 (Mass Assignment 방지)
|
|
7
|
+
* - Strict: 정의되지 않은 필드 있으면 에러
|
|
8
|
+
* - Coerce: 타입 자동 변환 (문자열 → 숫자 등)
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```typescript
|
|
12
|
+
* import { normalizeData, NormalizeMode } from "@mandujs/core/contract";
|
|
13
|
+
*
|
|
14
|
+
* const schema = z.object({ name: z.string(), age: z.number() });
|
|
15
|
+
* const input = { name: "Kim", age: 25, admin: true };
|
|
16
|
+
*
|
|
17
|
+
* // Strip 모드: 정의된 필드만 추출
|
|
18
|
+
* const result = normalizeData(schema, input, { mode: "strip" });
|
|
19
|
+
* // { name: "Kim", age: 25 }
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { z, type ZodTypeAny, type ZodObject, type ZodRawShape } from "zod";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 정규화 모드
|
|
27
|
+
* - strip: 정의되지 않은 필드 제거 (기본값, 권장)
|
|
28
|
+
* - strict: 정의되지 않은 필드 있으면 에러
|
|
29
|
+
* - passthrough: 모든 필드 허용 (정규화 안 함)
|
|
30
|
+
*/
|
|
31
|
+
export type NormalizeMode = "strip" | "strict" | "passthrough";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 정규화 옵션
|
|
35
|
+
*/
|
|
36
|
+
export interface NormalizeOptions {
|
|
37
|
+
/**
|
|
38
|
+
* 정규화 모드
|
|
39
|
+
* @default "strip"
|
|
40
|
+
*/
|
|
41
|
+
mode?: NormalizeMode;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Query/Params의 타입 자동 변환 (coerce) 활성화
|
|
45
|
+
* URL의 query string과 path params는 항상 문자열이므로
|
|
46
|
+
* 스키마에 정의된 타입으로 자동 변환
|
|
47
|
+
* @default true
|
|
48
|
+
*/
|
|
49
|
+
coerceQueryParams?: boolean;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 깊은 정규화 (중첩 객체에도 적용)
|
|
53
|
+
* @default true
|
|
54
|
+
*/
|
|
55
|
+
deep?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 전역 기본 정규화 옵션
|
|
60
|
+
*/
|
|
61
|
+
const DEFAULT_OPTIONS: Required<NormalizeOptions> = {
|
|
62
|
+
mode: "strip",
|
|
63
|
+
coerceQueryParams: true,
|
|
64
|
+
deep: true,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 전역 옵션 설정
|
|
69
|
+
*/
|
|
70
|
+
let globalOptions: Required<NormalizeOptions> = { ...DEFAULT_OPTIONS };
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 전역 정규화 옵션 설정
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* ```typescript
|
|
77
|
+
* setNormalizeOptions({
|
|
78
|
+
* mode: "strict",
|
|
79
|
+
* coerceQueryParams: true,
|
|
80
|
+
* });
|
|
81
|
+
* ```
|
|
82
|
+
*/
|
|
83
|
+
export function setNormalizeOptions(options: NormalizeOptions): void {
|
|
84
|
+
globalOptions = { ...DEFAULT_OPTIONS, ...options };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 현재 전역 옵션 조회
|
|
89
|
+
*/
|
|
90
|
+
export function getNormalizeOptions(): Required<NormalizeOptions> {
|
|
91
|
+
return { ...globalOptions };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 전역 옵션 초기화
|
|
96
|
+
*/
|
|
97
|
+
export function resetNormalizeOptions(): void {
|
|
98
|
+
globalOptions = { ...DEFAULT_OPTIONS };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* ZodObject 스키마에 정규화 모드 적용
|
|
103
|
+
*
|
|
104
|
+
* @param schema - Zod 객체 스키마
|
|
105
|
+
* @param mode - 정규화 모드
|
|
106
|
+
* @returns 모드가 적용된 스키마
|
|
107
|
+
*/
|
|
108
|
+
export function applyNormalizeMode<T extends ZodRawShape>(
|
|
109
|
+
schema: ZodObject<T>,
|
|
110
|
+
mode: NormalizeMode
|
|
111
|
+
): ZodObject<T> {
|
|
112
|
+
switch (mode) {
|
|
113
|
+
case "strip":
|
|
114
|
+
return schema.strip();
|
|
115
|
+
case "strict":
|
|
116
|
+
return schema.strict();
|
|
117
|
+
case "passthrough":
|
|
118
|
+
return schema.passthrough();
|
|
119
|
+
default:
|
|
120
|
+
return schema.strip();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 스키마가 ZodObject인지 확인
|
|
126
|
+
*/
|
|
127
|
+
function isZodObject(schema: ZodTypeAny): schema is ZodObject<ZodRawShape> {
|
|
128
|
+
return schema instanceof z.ZodObject;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 스키마에 정규화 적용
|
|
133
|
+
* ZodObject가 아닌 경우 원본 반환
|
|
134
|
+
*
|
|
135
|
+
* @param schema - Zod 스키마
|
|
136
|
+
* @param options - 정규화 옵션
|
|
137
|
+
* @returns 정규화된 스키마
|
|
138
|
+
*/
|
|
139
|
+
export function normalizeSchema<T extends ZodTypeAny>(
|
|
140
|
+
schema: T,
|
|
141
|
+
options?: NormalizeOptions
|
|
142
|
+
): T {
|
|
143
|
+
const opts = { ...globalOptions, ...options };
|
|
144
|
+
|
|
145
|
+
if (!isZodObject(schema)) {
|
|
146
|
+
return schema;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return applyNormalizeMode(schema, opts.mode) as T;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 데이터 정규화 실행
|
|
154
|
+
* 스키마에 정의된 필드만 추출하고 타입 변환
|
|
155
|
+
*
|
|
156
|
+
* @param schema - Zod 스키마
|
|
157
|
+
* @param data - 입력 데이터
|
|
158
|
+
* @param options - 정규화 옵션
|
|
159
|
+
* @returns 정규화된 데이터
|
|
160
|
+
* @throws ZodError - 검증 실패 시
|
|
161
|
+
*
|
|
162
|
+
* @example
|
|
163
|
+
* ```typescript
|
|
164
|
+
* const schema = z.object({ name: z.string(), age: z.number() });
|
|
165
|
+
*
|
|
166
|
+
* // Strip 모드 (기본)
|
|
167
|
+
* normalizeData(schema, { name: "Kim", age: 25, admin: true });
|
|
168
|
+
* // → { name: "Kim", age: 25 }
|
|
169
|
+
*
|
|
170
|
+
* // Strict 모드
|
|
171
|
+
* normalizeData(schema, { name: "Kim", age: 25, admin: true }, { mode: "strict" });
|
|
172
|
+
* // → ZodError: Unrecognized key(s) in object: 'admin'
|
|
173
|
+
* ```
|
|
174
|
+
*/
|
|
175
|
+
export function normalizeData<T extends ZodTypeAny>(
|
|
176
|
+
schema: T,
|
|
177
|
+
data: unknown,
|
|
178
|
+
options?: NormalizeOptions
|
|
179
|
+
): z.infer<T> {
|
|
180
|
+
const normalizedSchema = normalizeSchema(schema, options);
|
|
181
|
+
return normalizedSchema.parse(data);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* 안전한 데이터 정규화 (에러 시 null 반환)
|
|
186
|
+
*
|
|
187
|
+
* @param schema - Zod 스키마
|
|
188
|
+
* @param data - 입력 데이터
|
|
189
|
+
* @param options - 정규화 옵션
|
|
190
|
+
* @returns 정규화 결과
|
|
191
|
+
*/
|
|
192
|
+
export function safeNormalizeData<T extends ZodTypeAny>(
|
|
193
|
+
schema: T,
|
|
194
|
+
data: unknown,
|
|
195
|
+
options?: NormalizeOptions
|
|
196
|
+
): {
|
|
197
|
+
success: true;
|
|
198
|
+
data: z.infer<T>;
|
|
199
|
+
} | {
|
|
200
|
+
success: false;
|
|
201
|
+
error: z.ZodError;
|
|
202
|
+
} {
|
|
203
|
+
const normalizedSchema = normalizeSchema(schema, options);
|
|
204
|
+
const result = normalizedSchema.safeParse(data);
|
|
205
|
+
|
|
206
|
+
if (result.success) {
|
|
207
|
+
return { success: true, data: result.data };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return { success: false, error: result.error };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Query/Params용 coerce 스키마 생성
|
|
215
|
+
* URL에서 오는 값은 항상 문자열이므로 자동 변환 필요
|
|
216
|
+
*
|
|
217
|
+
* @example
|
|
218
|
+
* ```typescript
|
|
219
|
+
* // 원본 스키마
|
|
220
|
+
* const schema = z.object({
|
|
221
|
+
* page: z.number(),
|
|
222
|
+
* active: z.boolean(),
|
|
223
|
+
* });
|
|
224
|
+
*
|
|
225
|
+
* // Coerce 적용
|
|
226
|
+
* const coercedSchema = createCoerceSchema(schema);
|
|
227
|
+
* coercedSchema.parse({ page: "1", active: "true" });
|
|
228
|
+
* // → { page: 1, active: true }
|
|
229
|
+
* ```
|
|
230
|
+
*/
|
|
231
|
+
export function createCoerceSchema<T extends ZodRawShape>(
|
|
232
|
+
schema: ZodObject<T>
|
|
233
|
+
): ZodObject<T> {
|
|
234
|
+
const shape = schema.shape;
|
|
235
|
+
const coercedShape: Record<string, ZodTypeAny> = {};
|
|
236
|
+
|
|
237
|
+
for (const [key, value] of Object.entries(shape)) {
|
|
238
|
+
coercedShape[key] = applyCoercion(value as ZodTypeAny);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return z.object(coercedShape as T);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* 단일 스키마에 coercion 적용
|
|
246
|
+
*/
|
|
247
|
+
function applyCoercion(schema: ZodTypeAny): ZodTypeAny {
|
|
248
|
+
// ZodNumber → z.coerce.number()
|
|
249
|
+
if (schema instanceof z.ZodNumber) {
|
|
250
|
+
let coerced = z.coerce.number();
|
|
251
|
+
// 기존 체크 유지 (min, max 등)
|
|
252
|
+
const checks = (schema as any)._def.checks || [];
|
|
253
|
+
for (const check of checks) {
|
|
254
|
+
switch (check.kind) {
|
|
255
|
+
case "min":
|
|
256
|
+
coerced = check.inclusive
|
|
257
|
+
? coerced.gte(check.value)
|
|
258
|
+
: coerced.gt(check.value);
|
|
259
|
+
break;
|
|
260
|
+
case "max":
|
|
261
|
+
coerced = check.inclusive
|
|
262
|
+
? coerced.lte(check.value)
|
|
263
|
+
: coerced.lt(check.value);
|
|
264
|
+
break;
|
|
265
|
+
case "int":
|
|
266
|
+
coerced = coerced.int();
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return coerced;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ZodBoolean → z.coerce.boolean() 또는 커스텀 변환
|
|
274
|
+
if (schema instanceof z.ZodBoolean) {
|
|
275
|
+
// "true", "false", "1", "0" 처리
|
|
276
|
+
return z.preprocess((val) => {
|
|
277
|
+
if (typeof val === "string") {
|
|
278
|
+
if (val === "true" || val === "1") return true;
|
|
279
|
+
if (val === "false" || val === "0") return false;
|
|
280
|
+
}
|
|
281
|
+
return val;
|
|
282
|
+
}, z.boolean());
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ZodBigInt → z.coerce.bigint()
|
|
286
|
+
if (schema instanceof z.ZodBigInt) {
|
|
287
|
+
return z.coerce.bigint();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ZodDate → z.coerce.date()
|
|
291
|
+
if (schema instanceof z.ZodDate) {
|
|
292
|
+
return z.coerce.date();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ZodOptional → 내부 스키마에 coercion 적용
|
|
296
|
+
if (schema instanceof z.ZodOptional) {
|
|
297
|
+
return applyCoercion((schema as any)._def.innerType).optional();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ZodDefault → 내부 스키마에 coercion 적용
|
|
301
|
+
if (schema instanceof z.ZodDefault) {
|
|
302
|
+
const inner = applyCoercion((schema as any)._def.innerType);
|
|
303
|
+
return inner.default((schema as any)._def.defaultValue());
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ZodNullable → 내부 스키마에 coercion 적용
|
|
307
|
+
if (schema instanceof z.ZodNullable) {
|
|
308
|
+
return applyCoercion((schema as any)._def.innerType).nullable();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ZodArray → 배열 요소에 coercion 적용 (쿼리스트링 배열)
|
|
312
|
+
if (schema instanceof z.ZodArray) {
|
|
313
|
+
return z.array(applyCoercion((schema as any)._def.type));
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// 그 외는 원본 반환
|
|
317
|
+
return schema;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Request 데이터 전체 정규화
|
|
322
|
+
* query, body, params, headers 각각에 적절한 정규화 적용
|
|
323
|
+
*/
|
|
324
|
+
export interface NormalizedRequestData {
|
|
325
|
+
query?: unknown;
|
|
326
|
+
body?: unknown;
|
|
327
|
+
params?: unknown;
|
|
328
|
+
headers?: unknown;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export interface RequestSchemas {
|
|
332
|
+
query?: ZodTypeAny;
|
|
333
|
+
body?: ZodTypeAny;
|
|
334
|
+
params?: ZodTypeAny;
|
|
335
|
+
headers?: ZodTypeAny;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Request 데이터 정규화
|
|
340
|
+
* - query/params: coerce 적용 (문자열 → 숫자 등)
|
|
341
|
+
* - body: strip/strict 모드 적용
|
|
342
|
+
* - headers: 그대로 검증
|
|
343
|
+
*
|
|
344
|
+
* @param schemas - 각 필드별 스키마
|
|
345
|
+
* @param data - 원본 데이터
|
|
346
|
+
* @param options - 정규화 옵션
|
|
347
|
+
* @returns 정규화된 데이터
|
|
348
|
+
*/
|
|
349
|
+
export function normalizeRequestData(
|
|
350
|
+
schemas: RequestSchemas,
|
|
351
|
+
data: NormalizedRequestData,
|
|
352
|
+
options?: NormalizeOptions
|
|
353
|
+
): NormalizedRequestData {
|
|
354
|
+
const opts = { ...globalOptions, ...options };
|
|
355
|
+
const result: NormalizedRequestData = {};
|
|
356
|
+
|
|
357
|
+
// Query: coerce + strip
|
|
358
|
+
if (schemas.query && data.query !== undefined) {
|
|
359
|
+
let querySchema = schemas.query;
|
|
360
|
+
|
|
361
|
+
// coerce 적용
|
|
362
|
+
if (opts.coerceQueryParams && isZodObject(querySchema)) {
|
|
363
|
+
querySchema = createCoerceSchema(querySchema);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// strip/strict 적용
|
|
367
|
+
querySchema = normalizeSchema(querySchema, opts);
|
|
368
|
+
|
|
369
|
+
result.query = querySchema.parse(data.query);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Params: coerce + strip
|
|
373
|
+
if (schemas.params && data.params !== undefined) {
|
|
374
|
+
let paramsSchema = schemas.params;
|
|
375
|
+
|
|
376
|
+
// coerce 적용
|
|
377
|
+
if (opts.coerceQueryParams && isZodObject(paramsSchema)) {
|
|
378
|
+
paramsSchema = createCoerceSchema(paramsSchema);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// strip/strict 적용
|
|
382
|
+
paramsSchema = normalizeSchema(paramsSchema, opts);
|
|
383
|
+
|
|
384
|
+
result.params = paramsSchema.parse(data.params);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Body: strip/strict만 적용 (coerce 안 함 - JSON은 타입 보존)
|
|
388
|
+
if (schemas.body && data.body !== undefined) {
|
|
389
|
+
const bodySchema = normalizeSchema(schemas.body, opts);
|
|
390
|
+
result.body = bodySchema.parse(data.body);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Headers: 검증만 (정규화 안 함)
|
|
394
|
+
if (schemas.headers && data.headers !== undefined) {
|
|
395
|
+
result.headers = schemas.headers.parse(data.headers);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return result;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* 타입 유틸리티: 정규화된 데이터 타입 추론
|
|
403
|
+
*/
|
|
404
|
+
export type NormalizedData<T extends ZodTypeAny> = z.infer<T>;
|