@mandujs/core 0.4.3 → 0.5.1
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 +41 -41
- package/src/contract/index.ts +63 -0
- package/src/contract/schema.ts +110 -0
- package/src/contract/types.ts +134 -0
- package/src/contract/validator.ts +257 -0
- package/src/filling/filling.ts +50 -0
- package/src/generator/contract-glue.ts +285 -0
- package/src/generator/generate.ts +83 -0
- package/src/generator/index.ts +1 -0
- package/src/generator/templates.ts +68 -1
- package/src/guard/check.ts +5 -0
- package/src/guard/contract-guard.ts +221 -0
- package/src/guard/index.ts +1 -0
- package/src/guard/rules.ts +21 -0
- package/src/index.ts +2 -0
- package/src/openapi/generator.ts +480 -0
- package/src/openapi/index.ts +6 -0
- package/src/runtime/server.ts +9 -3
- package/src/spec/schema.ts +3 -0
package/package.json
CHANGED
|
@@ -1,41 +1,41 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@mandujs/core",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "./src/index.ts",
|
|
7
|
-
"types": "./src/index.ts",
|
|
8
|
-
"exports": {
|
|
9
|
-
".": "./src/index.ts",
|
|
10
|
-
"./client": "./src/client/index.ts",
|
|
11
|
-
"./*": "./src/*"
|
|
12
|
-
},
|
|
13
|
-
"files": [
|
|
14
|
-
"src/**/*"
|
|
15
|
-
],
|
|
16
|
-
"keywords": [
|
|
17
|
-
"mandu",
|
|
18
|
-
"framework",
|
|
19
|
-
"agent",
|
|
20
|
-
"ai",
|
|
21
|
-
"code-generation"
|
|
22
|
-
],
|
|
23
|
-
"repository": {
|
|
24
|
-
"type": "git",
|
|
25
|
-
"url": "https://github.com/konamgil/mandu.git",
|
|
26
|
-
"directory": "packages/core"
|
|
27
|
-
},
|
|
28
|
-
"author": "konamgil",
|
|
29
|
-
"license": "MIT",
|
|
30
|
-
"publishConfig": {
|
|
31
|
-
"access": "public"
|
|
32
|
-
},
|
|
33
|
-
"engines": {
|
|
34
|
-
"bun": ">=1.0.0"
|
|
35
|
-
},
|
|
36
|
-
"peerDependencies": {
|
|
37
|
-
"react": ">=18.0.0",
|
|
38
|
-
"react-dom": ">=18.0.0",
|
|
39
|
-
"zod": ">=3.0.0"
|
|
40
|
-
}
|
|
41
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@mandujs/core",
|
|
3
|
+
"version": "0.5.1",
|
|
4
|
+
"description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts",
|
|
10
|
+
"./client": "./src/client/index.ts",
|
|
11
|
+
"./*": "./src/*"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src/**/*"
|
|
15
|
+
],
|
|
16
|
+
"keywords": [
|
|
17
|
+
"mandu",
|
|
18
|
+
"framework",
|
|
19
|
+
"agent",
|
|
20
|
+
"ai",
|
|
21
|
+
"code-generation"
|
|
22
|
+
],
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "https://github.com/konamgil/mandu.git",
|
|
26
|
+
"directory": "packages/core"
|
|
27
|
+
},
|
|
28
|
+
"author": "konamgil",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"bun": ">=1.0.0"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"react": ">=18.0.0",
|
|
38
|
+
"react-dom": ">=18.0.0",
|
|
39
|
+
"zod": ">=3.0.0"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mandu Contract Module
|
|
3
|
+
* Contract-first API 정의 시스템
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export * from "./schema";
|
|
7
|
+
export * from "./types";
|
|
8
|
+
export * from "./validator";
|
|
9
|
+
|
|
10
|
+
import type { ContractDefinition, ContractInstance } from "./schema";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Create a Mandu API Contract
|
|
14
|
+
*
|
|
15
|
+
* Contract-first 방식으로 API 스키마를 정의합니다.
|
|
16
|
+
* 정의된 스키마는 다음에 활용됩니다:
|
|
17
|
+
* - TypeScript 타입 추론 (Slot에서 자동 완성)
|
|
18
|
+
* - 런타임 요청/응답 검증
|
|
19
|
+
* - OpenAPI 문서 자동 생성
|
|
20
|
+
* - Guard의 Contract-Slot 일관성 검사
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```typescript
|
|
24
|
+
* import { z } from "zod";
|
|
25
|
+
* import { Mandu } from "@mandujs/core";
|
|
26
|
+
*
|
|
27
|
+
* const UserSchema = z.object({
|
|
28
|
+
* id: z.string().uuid(),
|
|
29
|
+
* email: z.string().email(),
|
|
30
|
+
* name: z.string().min(2),
|
|
31
|
+
* });
|
|
32
|
+
*
|
|
33
|
+
* export default Mandu.contract({
|
|
34
|
+
* description: "사용자 관리 API",
|
|
35
|
+
* tags: ["users"],
|
|
36
|
+
*
|
|
37
|
+
* request: {
|
|
38
|
+
* GET: {
|
|
39
|
+
* query: z.object({
|
|
40
|
+
* page: z.coerce.number().default(1),
|
|
41
|
+
* limit: z.coerce.number().default(10),
|
|
42
|
+
* }),
|
|
43
|
+
* },
|
|
44
|
+
* POST: {
|
|
45
|
+
* body: UserSchema.omit({ id: true }),
|
|
46
|
+
* },
|
|
47
|
+
* },
|
|
48
|
+
*
|
|
49
|
+
* response: {
|
|
50
|
+
* 200: z.object({ data: z.array(UserSchema) }),
|
|
51
|
+
* 201: z.object({ data: UserSchema }),
|
|
52
|
+
* 400: z.object({ error: z.string() }),
|
|
53
|
+
* 404: z.object({ error: z.string() }),
|
|
54
|
+
* },
|
|
55
|
+
* });
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
export function createContract<T extends ContractDefinition>(definition: T): T & ContractInstance {
|
|
59
|
+
return {
|
|
60
|
+
...definition,
|
|
61
|
+
_validated: false,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mandu Contract Schema Types
|
|
3
|
+
* API 계약 정의를 위한 타입 시스템
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { z } from "zod";
|
|
7
|
+
|
|
8
|
+
/** HTTP Methods supported in contracts */
|
|
9
|
+
export type ContractMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
10
|
+
|
|
11
|
+
/** Request schema for a specific HTTP method */
|
|
12
|
+
export interface MethodRequestSchema {
|
|
13
|
+
/** Query parameters schema */
|
|
14
|
+
query?: z.ZodTypeAny;
|
|
15
|
+
/** Request body schema */
|
|
16
|
+
body?: z.ZodTypeAny;
|
|
17
|
+
/** Path parameters schema (for nested routes) */
|
|
18
|
+
params?: z.ZodTypeAny;
|
|
19
|
+
/** Request headers schema */
|
|
20
|
+
headers?: z.ZodTypeAny;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Nested route schema (e.g., ":id" for /users/:id) */
|
|
24
|
+
export interface NestedRouteSchema extends MethodRequestSchema {
|
|
25
|
+
GET?: MethodRequestSchema;
|
|
26
|
+
POST?: MethodRequestSchema;
|
|
27
|
+
PUT?: MethodRequestSchema;
|
|
28
|
+
PATCH?: MethodRequestSchema;
|
|
29
|
+
DELETE?: MethodRequestSchema;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Request schemas by method */
|
|
33
|
+
export interface ContractRequestSchema {
|
|
34
|
+
GET?: MethodRequestSchema;
|
|
35
|
+
POST?: MethodRequestSchema;
|
|
36
|
+
PUT?: MethodRequestSchema;
|
|
37
|
+
PATCH?: MethodRequestSchema;
|
|
38
|
+
DELETE?: MethodRequestSchema;
|
|
39
|
+
/** Nested routes (e.g., ":id" for /users/:id) */
|
|
40
|
+
[key: string]: MethodRequestSchema | NestedRouteSchema | undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Response schemas by status code */
|
|
44
|
+
export interface ContractResponseSchema {
|
|
45
|
+
200?: z.ZodTypeAny;
|
|
46
|
+
201?: z.ZodTypeAny;
|
|
47
|
+
204?: z.ZodTypeAny;
|
|
48
|
+
400?: z.ZodTypeAny;
|
|
49
|
+
401?: z.ZodTypeAny;
|
|
50
|
+
403?: z.ZodTypeAny;
|
|
51
|
+
404?: z.ZodTypeAny;
|
|
52
|
+
500?: z.ZodTypeAny;
|
|
53
|
+
[statusCode: number]: z.ZodTypeAny | undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Full contract schema definition */
|
|
57
|
+
export interface ContractSchema {
|
|
58
|
+
/** API description */
|
|
59
|
+
description?: string;
|
|
60
|
+
/** Tags for grouping (e.g., OpenAPI tags) */
|
|
61
|
+
tags?: string[];
|
|
62
|
+
/** Request schemas by method */
|
|
63
|
+
request: ContractRequestSchema;
|
|
64
|
+
/** Response schemas by status code */
|
|
65
|
+
response: ContractResponseSchema;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Contract definition input (what user provides) */
|
|
69
|
+
export interface ContractDefinition {
|
|
70
|
+
description?: string;
|
|
71
|
+
tags?: string[];
|
|
72
|
+
request: ContractRequestSchema;
|
|
73
|
+
response: ContractResponseSchema;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Contract instance with metadata */
|
|
77
|
+
export interface ContractInstance extends ContractSchema {
|
|
78
|
+
/** Unique identifier (derived from route id) */
|
|
79
|
+
_id?: string;
|
|
80
|
+
/** Whether this contract is validated */
|
|
81
|
+
_validated?: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Validation error detail */
|
|
85
|
+
export interface ContractValidationIssue {
|
|
86
|
+
/** Field path (e.g., "body.email") */
|
|
87
|
+
path: (string | number)[];
|
|
88
|
+
/** Error message */
|
|
89
|
+
message: string;
|
|
90
|
+
/** Zod error code */
|
|
91
|
+
code: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Validation error by type */
|
|
95
|
+
export interface ContractValidationError {
|
|
96
|
+
/** Error type (query, body, params, headers, response) */
|
|
97
|
+
type: "query" | "body" | "params" | "headers" | "response";
|
|
98
|
+
/** Validation issues */
|
|
99
|
+
issues: ContractValidationIssue[];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Validation result */
|
|
103
|
+
export interface ContractValidationResult {
|
|
104
|
+
/** Whether validation succeeded */
|
|
105
|
+
success: boolean;
|
|
106
|
+
/** Validation errors if failed */
|
|
107
|
+
errors?: ContractValidationError[];
|
|
108
|
+
/** Parsed/transformed data if successful */
|
|
109
|
+
data?: unknown;
|
|
110
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mandu Contract Type Inference
|
|
3
|
+
* Contract 스키마에서 TypeScript 타입 추론
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { z } from "zod";
|
|
7
|
+
import type {
|
|
8
|
+
ContractSchema,
|
|
9
|
+
ContractRequestSchema,
|
|
10
|
+
ContractResponseSchema,
|
|
11
|
+
MethodRequestSchema,
|
|
12
|
+
} from "./schema";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Extract inferred type from Zod schema
|
|
16
|
+
*/
|
|
17
|
+
type InferZod<T> = T extends z.ZodTypeAny ? z.infer<T> : never;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Infer request schema types for a single method
|
|
21
|
+
*/
|
|
22
|
+
type InferMethodRequest<T extends MethodRequestSchema | undefined> = T extends MethodRequestSchema
|
|
23
|
+
? {
|
|
24
|
+
query: T["query"] extends z.ZodTypeAny ? z.infer<T["query"]> : undefined;
|
|
25
|
+
body: T["body"] extends z.ZodTypeAny ? z.infer<T["body"]> : undefined;
|
|
26
|
+
params: T["params"] extends z.ZodTypeAny ? z.infer<T["params"]> : undefined;
|
|
27
|
+
headers: T["headers"] extends z.ZodTypeAny ? z.infer<T["headers"]> : undefined;
|
|
28
|
+
}
|
|
29
|
+
: undefined;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Infer all request schemas
|
|
33
|
+
*/
|
|
34
|
+
type InferContractRequest<T extends ContractRequestSchema> = {
|
|
35
|
+
[K in keyof T]: InferMethodRequest<T[K] extends MethodRequestSchema ? T[K] : undefined>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Infer all response schemas
|
|
40
|
+
*/
|
|
41
|
+
type InferContractResponse<T extends ContractResponseSchema> = {
|
|
42
|
+
[K in keyof T]: T[K] extends z.ZodTypeAny ? z.infer<T[K]> : never;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Infer full contract types
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```typescript
|
|
50
|
+
* const contract = Mandu.contract({
|
|
51
|
+
* request: {
|
|
52
|
+
* GET: { query: z.object({ page: z.number() }) },
|
|
53
|
+
* POST: { body: z.object({ name: z.string() }) },
|
|
54
|
+
* },
|
|
55
|
+
* response: {
|
|
56
|
+
* 200: z.object({ data: z.array(z.string()) }),
|
|
57
|
+
* },
|
|
58
|
+
* });
|
|
59
|
+
*
|
|
60
|
+
* type Contract = InferContract<typeof contract>;
|
|
61
|
+
* // {
|
|
62
|
+
* // request: {
|
|
63
|
+
* // GET: { query: { page: number }, body: undefined, params: undefined, headers: undefined },
|
|
64
|
+
* // POST: { query: undefined, body: { name: string }, params: undefined, headers: undefined },
|
|
65
|
+
* // },
|
|
66
|
+
* // response: {
|
|
67
|
+
* // 200: { data: string[] },
|
|
68
|
+
* // },
|
|
69
|
+
* // }
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
export type InferContract<T extends ContractSchema> = {
|
|
73
|
+
request: InferContractRequest<T["request"]>;
|
|
74
|
+
response: InferContractResponse<T["response"]>;
|
|
75
|
+
description: T["description"];
|
|
76
|
+
tags: T["tags"];
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Extract query type for a specific method
|
|
81
|
+
*/
|
|
82
|
+
export type InferQuery<
|
|
83
|
+
T extends ContractSchema,
|
|
84
|
+
M extends keyof T["request"]
|
|
85
|
+
> = T["request"][M] extends MethodRequestSchema
|
|
86
|
+
? T["request"][M]["query"] extends z.ZodTypeAny
|
|
87
|
+
? z.infer<T["request"][M]["query"]>
|
|
88
|
+
: undefined
|
|
89
|
+
: undefined;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Extract body type for a specific method
|
|
93
|
+
*/
|
|
94
|
+
export type InferBody<
|
|
95
|
+
T extends ContractSchema,
|
|
96
|
+
M extends keyof T["request"]
|
|
97
|
+
> = T["request"][M] extends MethodRequestSchema
|
|
98
|
+
? T["request"][M]["body"] extends z.ZodTypeAny
|
|
99
|
+
? z.infer<T["request"][M]["body"]>
|
|
100
|
+
: undefined
|
|
101
|
+
: undefined;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Extract params type for a specific method
|
|
105
|
+
*/
|
|
106
|
+
export type InferParams<
|
|
107
|
+
T extends ContractSchema,
|
|
108
|
+
M extends keyof T["request"]
|
|
109
|
+
> = T["request"][M] extends MethodRequestSchema
|
|
110
|
+
? T["request"][M]["params"] extends z.ZodTypeAny
|
|
111
|
+
? z.infer<T["request"][M]["params"]>
|
|
112
|
+
: undefined
|
|
113
|
+
: undefined;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Extract response type for a specific status code
|
|
117
|
+
*/
|
|
118
|
+
export type InferResponse<
|
|
119
|
+
T extends ContractSchema,
|
|
120
|
+
S extends keyof T["response"]
|
|
121
|
+
> = T["response"][S] extends z.ZodTypeAny ? z.infer<T["response"][S]> : never;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Helper type to get all defined methods in a contract
|
|
125
|
+
*/
|
|
126
|
+
export type ContractMethods<T extends ContractSchema> = Extract<
|
|
127
|
+
keyof T["request"],
|
|
128
|
+
"GET" | "POST" | "PUT" | "PATCH" | "DELETE"
|
|
129
|
+
>;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Helper type to get all defined status codes in a contract
|
|
133
|
+
*/
|
|
134
|
+
export type ContractStatusCodes<T extends ContractSchema> = keyof T["response"];
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mandu Contract Validator
|
|
3
|
+
* 런타임 요청/응답 검증
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { z } from "zod";
|
|
7
|
+
import type {
|
|
8
|
+
ContractSchema,
|
|
9
|
+
ContractValidationResult,
|
|
10
|
+
ContractValidationError,
|
|
11
|
+
ContractValidationIssue,
|
|
12
|
+
MethodRequestSchema,
|
|
13
|
+
} from "./schema";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Parse query string from URL
|
|
17
|
+
*/
|
|
18
|
+
function parseQueryString(url: string): Record<string, string | string[]> {
|
|
19
|
+
const result: Record<string, string | string[]> = {};
|
|
20
|
+
try {
|
|
21
|
+
const urlObj = new URL(url);
|
|
22
|
+
urlObj.searchParams.forEach((value, key) => {
|
|
23
|
+
const existing = result[key];
|
|
24
|
+
if (existing !== undefined) {
|
|
25
|
+
if (Array.isArray(existing)) {
|
|
26
|
+
existing.push(value);
|
|
27
|
+
} else {
|
|
28
|
+
result[key] = [existing, value];
|
|
29
|
+
}
|
|
30
|
+
} else {
|
|
31
|
+
result[key] = value;
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
} catch {
|
|
35
|
+
// Invalid URL, return empty object
|
|
36
|
+
}
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Convert Zod error to validation issues
|
|
42
|
+
*/
|
|
43
|
+
function zodErrorToIssues(error: z.ZodError): ContractValidationIssue[] {
|
|
44
|
+
return error.errors.map((issue) => ({
|
|
45
|
+
path: issue.path,
|
|
46
|
+
message: issue.message,
|
|
47
|
+
code: issue.code,
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Contract Validator
|
|
53
|
+
* Validates requests and responses against contract schemas
|
|
54
|
+
*/
|
|
55
|
+
export class ContractValidator {
|
|
56
|
+
constructor(private contract: ContractSchema) {}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Validate incoming request against contract
|
|
60
|
+
* @param req - The incoming request
|
|
61
|
+
* @param method - HTTP method
|
|
62
|
+
* @param pathParams - Path parameters extracted by router
|
|
63
|
+
*/
|
|
64
|
+
async validateRequest(
|
|
65
|
+
req: Request,
|
|
66
|
+
method: string,
|
|
67
|
+
pathParams: Record<string, string> = {}
|
|
68
|
+
): Promise<ContractValidationResult> {
|
|
69
|
+
const methodSchema = this.contract.request[method] as MethodRequestSchema | undefined;
|
|
70
|
+
if (!methodSchema) {
|
|
71
|
+
// No schema defined for this method, pass through
|
|
72
|
+
return { success: true };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const errors: ContractValidationError[] = [];
|
|
76
|
+
|
|
77
|
+
// Validate query parameters
|
|
78
|
+
if (methodSchema.query) {
|
|
79
|
+
const query = parseQueryString(req.url);
|
|
80
|
+
const result = methodSchema.query.safeParse(query);
|
|
81
|
+
if (!result.success) {
|
|
82
|
+
errors.push({
|
|
83
|
+
type: "query",
|
|
84
|
+
issues: zodErrorToIssues(result.error),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Validate path parameters
|
|
90
|
+
if (methodSchema.params) {
|
|
91
|
+
const result = methodSchema.params.safeParse(pathParams);
|
|
92
|
+
if (!result.success) {
|
|
93
|
+
errors.push({
|
|
94
|
+
type: "params",
|
|
95
|
+
issues: zodErrorToIssues(result.error),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Validate headers
|
|
101
|
+
if (methodSchema.headers) {
|
|
102
|
+
const headers: Record<string, string> = {};
|
|
103
|
+
req.headers.forEach((value, key) => {
|
|
104
|
+
headers[key.toLowerCase()] = value;
|
|
105
|
+
});
|
|
106
|
+
const result = methodSchema.headers.safeParse(headers);
|
|
107
|
+
if (!result.success) {
|
|
108
|
+
errors.push({
|
|
109
|
+
type: "headers",
|
|
110
|
+
issues: zodErrorToIssues(result.error),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Validate body (for methods that have body)
|
|
116
|
+
if (methodSchema.body) {
|
|
117
|
+
try {
|
|
118
|
+
const contentType = req.headers.get("content-type") || "";
|
|
119
|
+
let body: unknown;
|
|
120
|
+
|
|
121
|
+
if (contentType.includes("application/json")) {
|
|
122
|
+
body = await req.clone().json();
|
|
123
|
+
} else if (contentType.includes("application/x-www-form-urlencoded")) {
|
|
124
|
+
const formData = await req.clone().formData();
|
|
125
|
+
body = Object.fromEntries(formData.entries());
|
|
126
|
+
} else if (contentType.includes("multipart/form-data")) {
|
|
127
|
+
const formData = await req.clone().formData();
|
|
128
|
+
body = Object.fromEntries(formData.entries());
|
|
129
|
+
} else {
|
|
130
|
+
// Try JSON as default
|
|
131
|
+
try {
|
|
132
|
+
body = await req.clone().json();
|
|
133
|
+
} catch {
|
|
134
|
+
body = await req.clone().text();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const result = methodSchema.body.safeParse(body);
|
|
139
|
+
if (!result.success) {
|
|
140
|
+
errors.push({
|
|
141
|
+
type: "body",
|
|
142
|
+
issues: zodErrorToIssues(result.error),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
} catch (error) {
|
|
146
|
+
errors.push({
|
|
147
|
+
type: "body",
|
|
148
|
+
issues: [
|
|
149
|
+
{
|
|
150
|
+
path: [],
|
|
151
|
+
message: `Failed to parse request body: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
152
|
+
code: "invalid_type",
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (errors.length > 0) {
|
|
160
|
+
return { success: false, errors };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { success: true };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Validate response against contract (development mode)
|
|
168
|
+
* @param responseBody - The response body (already parsed)
|
|
169
|
+
* @param statusCode - HTTP status code
|
|
170
|
+
*/
|
|
171
|
+
validateResponse(responseBody: unknown, statusCode: number): ContractValidationResult {
|
|
172
|
+
const responseSchema = this.contract.response[statusCode];
|
|
173
|
+
if (!responseSchema) {
|
|
174
|
+
// No schema defined for this status code, pass through
|
|
175
|
+
return { success: true };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const result = responseSchema.safeParse(responseBody);
|
|
179
|
+
if (!result.success) {
|
|
180
|
+
return {
|
|
181
|
+
success: false,
|
|
182
|
+
errors: [
|
|
183
|
+
{
|
|
184
|
+
type: "response",
|
|
185
|
+
issues: zodErrorToIssues(result.error),
|
|
186
|
+
},
|
|
187
|
+
],
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return { success: true, data: result.data };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Get all defined methods in this contract
|
|
196
|
+
*/
|
|
197
|
+
getMethods(): string[] {
|
|
198
|
+
return Object.keys(this.contract.request).filter((key) =>
|
|
199
|
+
["GET", "POST", "PUT", "PATCH", "DELETE"].includes(key)
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Get all defined status codes in this contract
|
|
205
|
+
*/
|
|
206
|
+
getStatusCodes(): number[] {
|
|
207
|
+
return Object.keys(this.contract.response)
|
|
208
|
+
.map((k) => parseInt(k, 10))
|
|
209
|
+
.filter((n) => !isNaN(n));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Check if method has request schema
|
|
214
|
+
*/
|
|
215
|
+
hasMethodSchema(method: string): boolean {
|
|
216
|
+
return !!this.contract.request[method];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Check if status code has response schema
|
|
221
|
+
*/
|
|
222
|
+
hasResponseSchema(statusCode: number): boolean {
|
|
223
|
+
return !!this.contract.response[statusCode];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Get the underlying contract schema
|
|
228
|
+
*/
|
|
229
|
+
getSchema(): ContractSchema {
|
|
230
|
+
return this.contract;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Format validation errors for HTTP response
|
|
236
|
+
*/
|
|
237
|
+
export function formatValidationErrors(errors: ContractValidationError[]): {
|
|
238
|
+
error: string;
|
|
239
|
+
details: Array<{
|
|
240
|
+
type: string;
|
|
241
|
+
issues: Array<{
|
|
242
|
+
path: string;
|
|
243
|
+
message: string;
|
|
244
|
+
}>;
|
|
245
|
+
}>;
|
|
246
|
+
} {
|
|
247
|
+
return {
|
|
248
|
+
error: "Validation Error",
|
|
249
|
+
details: errors.map((e) => ({
|
|
250
|
+
type: e.type,
|
|
251
|
+
issues: e.issues.map((issue) => ({
|
|
252
|
+
path: issue.path.join(".") || "(root)",
|
|
253
|
+
message: issue.message,
|
|
254
|
+
})),
|
|
255
|
+
})),
|
|
256
|
+
};
|
|
257
|
+
}
|
package/src/filling/filling.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { ManduContext, NEXT_SYMBOL, ValidationError } from "./context";
|
|
7
7
|
import { ErrorClassifier, formatErrorResponse, ErrorCode } from "../error";
|
|
8
|
+
import { createContract, type ContractDefinition, type ContractInstance } from "../contract";
|
|
8
9
|
|
|
9
10
|
/** Handler function type */
|
|
10
11
|
export type Handler = (ctx: ManduContext) => Response | Promise<Response>;
|
|
@@ -318,6 +319,55 @@ export const Mandu = {
|
|
|
318
319
|
return new ManduFilling<TLoaderData>();
|
|
319
320
|
},
|
|
320
321
|
|
|
322
|
+
/**
|
|
323
|
+
* Create an API contract (schema-first definition)
|
|
324
|
+
*
|
|
325
|
+
* Contract-first 방식으로 API 스키마를 정의합니다.
|
|
326
|
+
* 정의된 스키마는 다음에 활용됩니다:
|
|
327
|
+
* - TypeScript 타입 추론 (Slot에서 자동 완성)
|
|
328
|
+
* - 런타임 요청/응답 검증
|
|
329
|
+
* - OpenAPI 문서 자동 생성
|
|
330
|
+
* - Guard의 Contract-Slot 일관성 검사
|
|
331
|
+
*
|
|
332
|
+
* @example
|
|
333
|
+
* ```typescript
|
|
334
|
+
* import { z } from "zod";
|
|
335
|
+
* import { Mandu } from "@mandujs/core";
|
|
336
|
+
*
|
|
337
|
+
* const UserSchema = z.object({
|
|
338
|
+
* id: z.string().uuid(),
|
|
339
|
+
* email: z.string().email(),
|
|
340
|
+
* name: z.string().min(2),
|
|
341
|
+
* });
|
|
342
|
+
*
|
|
343
|
+
* export default Mandu.contract({
|
|
344
|
+
* description: "사용자 관리 API",
|
|
345
|
+
* tags: ["users"],
|
|
346
|
+
*
|
|
347
|
+
* request: {
|
|
348
|
+
* GET: {
|
|
349
|
+
* query: z.object({
|
|
350
|
+
* page: z.coerce.number().default(1),
|
|
351
|
+
* limit: z.coerce.number().default(10),
|
|
352
|
+
* }),
|
|
353
|
+
* },
|
|
354
|
+
* POST: {
|
|
355
|
+
* body: UserSchema.omit({ id: true }),
|
|
356
|
+
* },
|
|
357
|
+
* },
|
|
358
|
+
*
|
|
359
|
+
* response: {
|
|
360
|
+
* 200: z.object({ data: z.array(UserSchema) }),
|
|
361
|
+
* 201: z.object({ data: UserSchema }),
|
|
362
|
+
* 400: z.object({ error: z.string() }),
|
|
363
|
+
* },
|
|
364
|
+
* });
|
|
365
|
+
* ```
|
|
366
|
+
*/
|
|
367
|
+
contract<T extends ContractDefinition>(definition: T): T & ContractInstance {
|
|
368
|
+
return createContract(definition);
|
|
369
|
+
},
|
|
370
|
+
|
|
321
371
|
/**
|
|
322
372
|
* Create context manually (for testing)
|
|
323
373
|
*/
|