@mandujs/core 0.8.2 → 0.9.0
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 +9 -1
- package/src/brain/adapters/base.ts +120 -0
- package/src/brain/adapters/index.ts +8 -0
- package/src/brain/adapters/ollama.ts +249 -0
- package/src/brain/brain.ts +324 -0
- package/src/brain/doctor/analyzer.ts +366 -0
- package/src/brain/doctor/index.ts +40 -0
- package/src/brain/doctor/patcher.ts +349 -0
- package/src/brain/doctor/reporter.ts +336 -0
- package/src/brain/index.ts +45 -0
- package/src/brain/memory.ts +154 -0
- package/src/brain/permissions.ts +270 -0
- package/src/brain/types.ts +268 -0
- package/src/contract/contract.test.ts +381 -0
- package/src/contract/integration.test.ts +394 -0
- package/src/contract/validator.ts +113 -8
- package/src/generator/contract-glue.test.ts +211 -0
- package/src/guard/check.ts +51 -1
- package/src/guard/contract-guard.test.ts +303 -0
- package/src/guard/rules.ts +37 -0
- package/src/index.ts +2 -0
- package/src/openapi/openapi.test.ts +277 -0
- package/src/slot/validator.test.ts +203 -0
- package/src/slot/validator.ts +236 -17
- package/src/watcher/index.ts +44 -0
- package/src/watcher/reporter.ts +232 -0
- package/src/watcher/rules.ts +248 -0
- package/src/watcher/watcher.ts +330 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract System Integration Tests
|
|
3
|
+
* End-to-end testing of the Contract-first API workflow
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, test, expect } from "bun:test";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { createContract } from "./index";
|
|
9
|
+
import { ContractValidator } from "./validator";
|
|
10
|
+
import { zodToOpenAPISchema } from "../openapi/generator";
|
|
11
|
+
import { generateContractTemplate, generateContractTypeGlue } from "../generator/contract-glue";
|
|
12
|
+
import type { RouteSpec } from "../spec/schema";
|
|
13
|
+
|
|
14
|
+
describe("Contract System Integration", () => {
|
|
15
|
+
// Define a realistic User schema
|
|
16
|
+
const UserSchema = z.object({
|
|
17
|
+
id: z.string().uuid(),
|
|
18
|
+
email: z.string().email(),
|
|
19
|
+
name: z.string().min(2).max(100),
|
|
20
|
+
role: z.enum(["admin", "user", "guest"]),
|
|
21
|
+
createdAt: z.string().datetime(),
|
|
22
|
+
metadata: z
|
|
23
|
+
.object({
|
|
24
|
+
lastLogin: z.string().datetime().optional(),
|
|
25
|
+
loginCount: z.number().int().min(0).default(0),
|
|
26
|
+
})
|
|
27
|
+
.optional(),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const CreateUserInput = UserSchema.omit({ id: true, createdAt: true, metadata: true });
|
|
31
|
+
|
|
32
|
+
const UserListQuery = z.object({
|
|
33
|
+
page: z.coerce.number().int().min(1).default(1),
|
|
34
|
+
limit: z.coerce.number().int().min(1).max(100).default(10),
|
|
35
|
+
role: z.enum(["admin", "user", "guest"]).optional(),
|
|
36
|
+
search: z.string().optional(),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Create the contract
|
|
40
|
+
const usersContract = createContract({
|
|
41
|
+
description: "User Management API",
|
|
42
|
+
tags: ["users", "admin"],
|
|
43
|
+
|
|
44
|
+
request: {
|
|
45
|
+
GET: {
|
|
46
|
+
query: UserListQuery,
|
|
47
|
+
},
|
|
48
|
+
POST: {
|
|
49
|
+
body: CreateUserInput,
|
|
50
|
+
},
|
|
51
|
+
PUT: {
|
|
52
|
+
params: z.object({ id: z.string().uuid() }),
|
|
53
|
+
body: CreateUserInput.partial(),
|
|
54
|
+
},
|
|
55
|
+
DELETE: {
|
|
56
|
+
params: z.object({ id: z.string().uuid() }),
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
response: {
|
|
61
|
+
200: z.object({
|
|
62
|
+
data: z.union([UserSchema, z.array(UserSchema)]),
|
|
63
|
+
pagination: z
|
|
64
|
+
.object({
|
|
65
|
+
page: z.number(),
|
|
66
|
+
limit: z.number(),
|
|
67
|
+
total: z.number(),
|
|
68
|
+
})
|
|
69
|
+
.optional(),
|
|
70
|
+
}),
|
|
71
|
+
201: z.object({ data: UserSchema }),
|
|
72
|
+
204: z.void(),
|
|
73
|
+
400: z.object({
|
|
74
|
+
error: z.string(),
|
|
75
|
+
details: z
|
|
76
|
+
.array(
|
|
77
|
+
z.object({
|
|
78
|
+
type: z.string(),
|
|
79
|
+
issues: z.array(z.object({ path: z.string(), message: z.string() })),
|
|
80
|
+
})
|
|
81
|
+
)
|
|
82
|
+
.optional(),
|
|
83
|
+
}),
|
|
84
|
+
404: z.object({ error: z.string() }),
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("Contract Creation", () => {
|
|
89
|
+
test("should create contract with all properties", () => {
|
|
90
|
+
expect(usersContract.description).toBe("User Management API");
|
|
91
|
+
expect(usersContract.tags).toEqual(["users", "admin"]);
|
|
92
|
+
expect(usersContract.request.GET).toBeDefined();
|
|
93
|
+
expect(usersContract.request.POST).toBeDefined();
|
|
94
|
+
expect(usersContract.request.PUT).toBeDefined();
|
|
95
|
+
expect(usersContract.request.DELETE).toBeDefined();
|
|
96
|
+
expect(usersContract.response[200]).toBeDefined();
|
|
97
|
+
expect(usersContract.response[201]).toBeDefined();
|
|
98
|
+
expect(usersContract.response[204]).toBeDefined();
|
|
99
|
+
expect(usersContract.response[400]).toBeDefined();
|
|
100
|
+
expect(usersContract.response[404]).toBeDefined();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("Request Validation Flow", () => {
|
|
105
|
+
const validator = new ContractValidator(usersContract);
|
|
106
|
+
|
|
107
|
+
test("should validate successful GET request", async () => {
|
|
108
|
+
const req = new Request("http://localhost/api/users?page=2&limit=20&role=admin");
|
|
109
|
+
const result = await validator.validateRequest(req, "GET");
|
|
110
|
+
|
|
111
|
+
expect(result.success).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("should validate successful POST request", async () => {
|
|
115
|
+
const req = new Request("http://localhost/api/users", {
|
|
116
|
+
method: "POST",
|
|
117
|
+
headers: { "Content-Type": "application/json" },
|
|
118
|
+
body: JSON.stringify({
|
|
119
|
+
email: "newuser@example.com",
|
|
120
|
+
name: "New User",
|
|
121
|
+
role: "user",
|
|
122
|
+
}),
|
|
123
|
+
});
|
|
124
|
+
const result = await validator.validateRequest(req, "POST");
|
|
125
|
+
|
|
126
|
+
expect(result.success).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("should fail POST with invalid email", async () => {
|
|
130
|
+
const req = new Request("http://localhost/api/users", {
|
|
131
|
+
method: "POST",
|
|
132
|
+
headers: { "Content-Type": "application/json" },
|
|
133
|
+
body: JSON.stringify({
|
|
134
|
+
email: "invalid-email",
|
|
135
|
+
name: "Test User",
|
|
136
|
+
role: "user",
|
|
137
|
+
}),
|
|
138
|
+
});
|
|
139
|
+
const result = await validator.validateRequest(req, "POST");
|
|
140
|
+
|
|
141
|
+
expect(result.success).toBe(false);
|
|
142
|
+
expect(result.errors![0].type).toBe("body");
|
|
143
|
+
expect(result.errors![0].issues.some((i) => i.path.includes("email"))).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("should fail POST with invalid role", async () => {
|
|
147
|
+
const req = new Request("http://localhost/api/users", {
|
|
148
|
+
method: "POST",
|
|
149
|
+
headers: { "Content-Type": "application/json" },
|
|
150
|
+
body: JSON.stringify({
|
|
151
|
+
email: "test@example.com",
|
|
152
|
+
name: "Test User",
|
|
153
|
+
role: "superadmin", // Invalid
|
|
154
|
+
}),
|
|
155
|
+
});
|
|
156
|
+
const result = await validator.validateRequest(req, "POST");
|
|
157
|
+
|
|
158
|
+
expect(result.success).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("should validate PUT with params and body", async () => {
|
|
162
|
+
const req = new Request("http://localhost/api/users/550e8400-e29b-41d4-a716-446655440000", {
|
|
163
|
+
method: "PUT",
|
|
164
|
+
headers: { "Content-Type": "application/json" },
|
|
165
|
+
body: JSON.stringify({
|
|
166
|
+
name: "Updated Name",
|
|
167
|
+
}),
|
|
168
|
+
});
|
|
169
|
+
const result = await validator.validateRequest(req, "PUT", {
|
|
170
|
+
id: "550e8400-e29b-41d4-a716-446655440000",
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
expect(result.success).toBe(true);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("should fail PUT with invalid UUID param", async () => {
|
|
177
|
+
const req = new Request("http://localhost/api/users/invalid-id", {
|
|
178
|
+
method: "PUT",
|
|
179
|
+
headers: { "Content-Type": "application/json" },
|
|
180
|
+
body: JSON.stringify({
|
|
181
|
+
name: "Updated Name",
|
|
182
|
+
}),
|
|
183
|
+
});
|
|
184
|
+
const result = await validator.validateRequest(req, "PUT", {
|
|
185
|
+
id: "invalid-id",
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
expect(result.success).toBe(false);
|
|
189
|
+
expect(result.errors![0].type).toBe("params");
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("Response Validation Flow", () => {
|
|
194
|
+
const validator = new ContractValidator(usersContract);
|
|
195
|
+
|
|
196
|
+
test("should validate 200 response with user list", () => {
|
|
197
|
+
const response = {
|
|
198
|
+
data: [
|
|
199
|
+
{
|
|
200
|
+
id: "550e8400-e29b-41d4-a716-446655440000",
|
|
201
|
+
email: "user@example.com",
|
|
202
|
+
name: "Test User",
|
|
203
|
+
role: "user",
|
|
204
|
+
createdAt: "2024-01-15T10:30:00Z",
|
|
205
|
+
},
|
|
206
|
+
],
|
|
207
|
+
pagination: {
|
|
208
|
+
page: 1,
|
|
209
|
+
limit: 10,
|
|
210
|
+
total: 100,
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
const result = validator.validateResponse(response, 200);
|
|
214
|
+
|
|
215
|
+
expect(result.success).toBe(true);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("should validate 201 response with created user", () => {
|
|
219
|
+
const response = {
|
|
220
|
+
data: {
|
|
221
|
+
id: "550e8400-e29b-41d4-a716-446655440000",
|
|
222
|
+
email: "newuser@example.com",
|
|
223
|
+
name: "New User",
|
|
224
|
+
role: "user",
|
|
225
|
+
createdAt: "2024-01-15T10:30:00Z",
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
const result = validator.validateResponse(response, 201);
|
|
229
|
+
|
|
230
|
+
expect(result.success).toBe(true);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("should validate 400 response with validation errors", () => {
|
|
234
|
+
const response = {
|
|
235
|
+
error: "Validation Error",
|
|
236
|
+
details: [
|
|
237
|
+
{
|
|
238
|
+
type: "body",
|
|
239
|
+
issues: [
|
|
240
|
+
{ path: "email", message: "Invalid email format" },
|
|
241
|
+
{ path: "name", message: "Name is required" },
|
|
242
|
+
],
|
|
243
|
+
},
|
|
244
|
+
],
|
|
245
|
+
};
|
|
246
|
+
const result = validator.validateResponse(response, 400);
|
|
247
|
+
|
|
248
|
+
expect(result.success).toBe(true);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("should fail 200 response with invalid data", () => {
|
|
252
|
+
const response = {
|
|
253
|
+
data: [
|
|
254
|
+
{
|
|
255
|
+
id: "not-a-uuid",
|
|
256
|
+
email: "invalid",
|
|
257
|
+
name: "X", // Too short
|
|
258
|
+
role: "unknown", // Invalid
|
|
259
|
+
createdAt: "not-a-date",
|
|
260
|
+
},
|
|
261
|
+
],
|
|
262
|
+
};
|
|
263
|
+
const result = validator.validateResponse(response, 200);
|
|
264
|
+
|
|
265
|
+
expect(result.success).toBe(false);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe("OpenAPI Schema Generation", () => {
|
|
270
|
+
test("should convert UserSchema to OpenAPI", () => {
|
|
271
|
+
const openApiSchema = zodToOpenAPISchema(UserSchema);
|
|
272
|
+
|
|
273
|
+
expect(openApiSchema.type).toBe("object");
|
|
274
|
+
expect(openApiSchema.properties!.id.format).toBe("uuid");
|
|
275
|
+
expect(openApiSchema.properties!.email.format).toBe("email");
|
|
276
|
+
expect(openApiSchema.properties!.name.minLength).toBe(2);
|
|
277
|
+
expect(openApiSchema.properties!.name.maxLength).toBe(100);
|
|
278
|
+
expect(openApiSchema.properties!.role.enum).toEqual(["admin", "user", "guest"]);
|
|
279
|
+
expect(openApiSchema.required).toContain("id");
|
|
280
|
+
expect(openApiSchema.required).toContain("email");
|
|
281
|
+
expect(openApiSchema.required).toContain("name");
|
|
282
|
+
expect(openApiSchema.required).not.toContain("metadata");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("should convert UserListQuery to OpenAPI", () => {
|
|
286
|
+
const openApiSchema = zodToOpenAPISchema(UserListQuery);
|
|
287
|
+
|
|
288
|
+
expect(openApiSchema.type).toBe("object");
|
|
289
|
+
expect(openApiSchema.properties!.page.type).toBe("integer");
|
|
290
|
+
expect(openApiSchema.properties!.page.default).toBe(1);
|
|
291
|
+
expect(openApiSchema.properties!.limit.maximum).toBe(100);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe("Contract Template Generation", () => {
|
|
296
|
+
test("should generate complete contract template", () => {
|
|
297
|
+
const route: RouteSpec = {
|
|
298
|
+
id: "users",
|
|
299
|
+
pattern: "/api/users",
|
|
300
|
+
kind: "api",
|
|
301
|
+
module: "generated/routes/api/users.ts",
|
|
302
|
+
methods: ["GET", "POST", "PUT", "DELETE"],
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const template = generateContractTemplate(route);
|
|
306
|
+
|
|
307
|
+
// Should include all methods
|
|
308
|
+
expect(template).toContain("GET: {");
|
|
309
|
+
expect(template).toContain("POST: {");
|
|
310
|
+
expect(template).toContain("PUT: {");
|
|
311
|
+
expect(template).toContain("DELETE: {");
|
|
312
|
+
|
|
313
|
+
// Should be valid TypeScript syntax (basic check)
|
|
314
|
+
expect(template).toContain('import { z } from "zod"');
|
|
315
|
+
expect(template).toContain("export default Mandu.contract({");
|
|
316
|
+
expect(template).toContain("});");
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
describe("Type Glue Generation", () => {
|
|
321
|
+
test("should generate type glue for route", () => {
|
|
322
|
+
const route: RouteSpec = {
|
|
323
|
+
id: "users",
|
|
324
|
+
pattern: "/api/users",
|
|
325
|
+
kind: "api",
|
|
326
|
+
module: "generated/routes/api/users.ts",
|
|
327
|
+
contractModule: "spec/contracts/users.contract.ts",
|
|
328
|
+
slotModule: "spec/slots/users.slot.ts",
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
const glue = generateContractTypeGlue(route);
|
|
332
|
+
|
|
333
|
+
expect(glue).toContain("import type { InferContract");
|
|
334
|
+
expect(glue).toContain("export type UsersContract");
|
|
335
|
+
expect(glue).toContain("export type UsersGetQuery");
|
|
336
|
+
expect(glue).toContain("export type UsersPostBody");
|
|
337
|
+
expect(glue).toContain("export type UsersResponse200");
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
describe("Full Workflow Simulation", () => {
|
|
342
|
+
test("should complete full contract-first workflow", async () => {
|
|
343
|
+
// Step 1: Create contract
|
|
344
|
+
const contract = createContract({
|
|
345
|
+
description: "Test API",
|
|
346
|
+
request: {
|
|
347
|
+
POST: {
|
|
348
|
+
body: z.object({
|
|
349
|
+
title: z.string().min(1),
|
|
350
|
+
content: z.string(),
|
|
351
|
+
}),
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
response: {
|
|
355
|
+
201: z.object({ id: z.number(), title: z.string() }),
|
|
356
|
+
400: z.object({ error: z.string() }),
|
|
357
|
+
},
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
expect(contract).toBeDefined();
|
|
361
|
+
|
|
362
|
+
// Step 2: Create validator
|
|
363
|
+
const validator = new ContractValidator(contract);
|
|
364
|
+
|
|
365
|
+
// Step 3: Validate valid request
|
|
366
|
+
const validReq = new Request("http://localhost/api/posts", {
|
|
367
|
+
method: "POST",
|
|
368
|
+
headers: { "Content-Type": "application/json" },
|
|
369
|
+
body: JSON.stringify({ title: "Hello", content: "World" }),
|
|
370
|
+
});
|
|
371
|
+
const validResult = await validator.validateRequest(validReq, "POST");
|
|
372
|
+
expect(validResult.success).toBe(true);
|
|
373
|
+
|
|
374
|
+
// Step 4: Validate invalid request
|
|
375
|
+
const invalidReq = new Request("http://localhost/api/posts", {
|
|
376
|
+
method: "POST",
|
|
377
|
+
headers: { "Content-Type": "application/json" },
|
|
378
|
+
body: JSON.stringify({ title: "", content: "World" }),
|
|
379
|
+
});
|
|
380
|
+
const invalidResult = await validator.validateRequest(invalidReq, "POST");
|
|
381
|
+
expect(invalidResult.success).toBe(false);
|
|
382
|
+
|
|
383
|
+
// Step 5: Validate response
|
|
384
|
+
const response = { id: 1, title: "Hello" };
|
|
385
|
+
const responseResult = validator.validateResponse(response, 201);
|
|
386
|
+
expect(responseResult.success).toBe(true);
|
|
387
|
+
|
|
388
|
+
// Step 6: Generate OpenAPI schema
|
|
389
|
+
const openApiSchema = zodToOpenAPISchema(contract.response[201]);
|
|
390
|
+
expect(openApiSchema.type).toBe("object");
|
|
391
|
+
expect(openApiSchema.properties!.id.type).toBe("number");
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
});
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Mandu Contract Validator
|
|
3
3
|
* 런타임 요청/응답 검증
|
|
4
|
+
*
|
|
5
|
+
* 모드:
|
|
6
|
+
* - lenient (기본): 응답 검증 실패 시 경고만 출력
|
|
7
|
+
* - strict: 응답 검증 실패 시 에러 반환 (프로덕션 안전성)
|
|
4
8
|
*/
|
|
5
9
|
|
|
6
10
|
import type { z } from "zod";
|
|
@@ -12,6 +16,21 @@ import type {
|
|
|
12
16
|
MethodRequestSchema,
|
|
13
17
|
} from "./schema";
|
|
14
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Validator 옵션
|
|
21
|
+
*/
|
|
22
|
+
export interface ContractValidatorOptions {
|
|
23
|
+
/**
|
|
24
|
+
* strict: 응답 검증 실패 시 에러 Response 반환
|
|
25
|
+
* lenient: 응답 검증 실패 시 경고만 출력 (기본값)
|
|
26
|
+
*/
|
|
27
|
+
mode?: "strict" | "lenient";
|
|
28
|
+
/**
|
|
29
|
+
* 응답 검증 실패 시 커스텀 에러 핸들러
|
|
30
|
+
*/
|
|
31
|
+
onResponseViolation?: (errors: ContractValidationError[], statusCode: number) => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
15
34
|
/**
|
|
16
35
|
* Parse query string from URL
|
|
17
36
|
*/
|
|
@@ -53,7 +72,31 @@ function zodErrorToIssues(error: z.ZodError): ContractValidationIssue[] {
|
|
|
53
72
|
* Validates requests and responses against contract schemas
|
|
54
73
|
*/
|
|
55
74
|
export class ContractValidator {
|
|
56
|
-
|
|
75
|
+
private options: Required<ContractValidatorOptions>;
|
|
76
|
+
|
|
77
|
+
constructor(
|
|
78
|
+
private contract: ContractSchema,
|
|
79
|
+
options: ContractValidatorOptions = {}
|
|
80
|
+
) {
|
|
81
|
+
this.options = {
|
|
82
|
+
mode: options.mode ?? "lenient",
|
|
83
|
+
onResponseViolation: options.onResponseViolation ?? (() => {}),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 현재 모드 확인
|
|
89
|
+
*/
|
|
90
|
+
isStrictMode(): boolean {
|
|
91
|
+
return this.options.mode === "strict";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 모드 변경
|
|
96
|
+
*/
|
|
97
|
+
setMode(mode: "strict" | "lenient"): void {
|
|
98
|
+
this.options.mode = mode;
|
|
99
|
+
}
|
|
57
100
|
|
|
58
101
|
/**
|
|
59
102
|
* Validate incoming request against contract
|
|
@@ -164,7 +207,7 @@ export class ContractValidator {
|
|
|
164
207
|
}
|
|
165
208
|
|
|
166
209
|
/**
|
|
167
|
-
* Validate response against contract
|
|
210
|
+
* Validate response against contract
|
|
168
211
|
* @param responseBody - The response body (already parsed)
|
|
169
212
|
* @param statusCode - HTTP status code
|
|
170
213
|
*/
|
|
@@ -177,20 +220,82 @@ export class ContractValidator {
|
|
|
177
220
|
|
|
178
221
|
const result = responseSchema.safeParse(responseBody);
|
|
179
222
|
if (!result.success) {
|
|
223
|
+
const errors: ContractValidationError[] = [
|
|
224
|
+
{
|
|
225
|
+
type: "response",
|
|
226
|
+
issues: zodErrorToIssues(result.error),
|
|
227
|
+
},
|
|
228
|
+
];
|
|
229
|
+
|
|
230
|
+
// 커스텀 핸들러 호출
|
|
231
|
+
this.options.onResponseViolation(errors, statusCode);
|
|
232
|
+
|
|
180
233
|
return {
|
|
181
234
|
success: false,
|
|
182
|
-
errors
|
|
183
|
-
{
|
|
184
|
-
type: "response",
|
|
185
|
-
issues: zodErrorToIssues(result.error),
|
|
186
|
-
},
|
|
187
|
-
],
|
|
235
|
+
errors,
|
|
188
236
|
};
|
|
189
237
|
}
|
|
190
238
|
|
|
191
239
|
return { success: true, data: result.data };
|
|
192
240
|
}
|
|
193
241
|
|
|
242
|
+
/**
|
|
243
|
+
* Validate response and return error Response in strict mode
|
|
244
|
+
* @param response - The original Response object
|
|
245
|
+
* @returns Original response or error response
|
|
246
|
+
*/
|
|
247
|
+
async validateResponseStrict(response: Response): Promise<{
|
|
248
|
+
valid: boolean;
|
|
249
|
+
response: Response;
|
|
250
|
+
errors?: ContractValidationError[];
|
|
251
|
+
}> {
|
|
252
|
+
// Clone response to read body
|
|
253
|
+
const cloned = response.clone();
|
|
254
|
+
const contentType = response.headers.get("content-type") || "";
|
|
255
|
+
|
|
256
|
+
// Only validate JSON responses
|
|
257
|
+
if (!contentType.includes("application/json")) {
|
|
258
|
+
return { valid: true, response };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
let body: unknown;
|
|
262
|
+
try {
|
|
263
|
+
body = await cloned.json();
|
|
264
|
+
} catch {
|
|
265
|
+
return { valid: true, response }; // Can't parse, skip validation
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const result = this.validateResponse(body, response.status);
|
|
269
|
+
|
|
270
|
+
if (!result.success) {
|
|
271
|
+
if (this.options.mode === "strict") {
|
|
272
|
+
// strict 모드: 에러 응답 반환
|
|
273
|
+
const errorResponse = Response.json(
|
|
274
|
+
{
|
|
275
|
+
errorType: "CONTRACT_VIOLATION",
|
|
276
|
+
code: "MANDU_C001",
|
|
277
|
+
message: "Response does not match contract schema",
|
|
278
|
+
summary: "응답이 Contract 스키마와 일치하지 않습니다",
|
|
279
|
+
statusCode: response.status,
|
|
280
|
+
violations: result.errors,
|
|
281
|
+
timestamp: new Date().toISOString(),
|
|
282
|
+
},
|
|
283
|
+
{ status: 500 }
|
|
284
|
+
);
|
|
285
|
+
return { valid: false, response: errorResponse, errors: result.errors };
|
|
286
|
+
} else {
|
|
287
|
+
// lenient 모드: 경고만 출력하고 원래 응답 반환
|
|
288
|
+
console.warn(
|
|
289
|
+
"\x1b[33m[Mandu] Contract violation in response:\x1b[0m",
|
|
290
|
+
result.errors
|
|
291
|
+
);
|
|
292
|
+
return { valid: false, response, errors: result.errors };
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return { valid: true, response };
|
|
297
|
+
}
|
|
298
|
+
|
|
194
299
|
/**
|
|
195
300
|
* Get all defined methods in this contract
|
|
196
301
|
*/
|