@mandujs/core 0.4.2 → 0.5.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 +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 +79 -4
- 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/spec/schema.ts +3 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mandu Contract Type Glue Generator
|
|
3
|
+
* Contract에서 TypeScript 타입 글루 코드 생성
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { RouteSpec } from "../spec/schema";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Convert string to PascalCase
|
|
10
|
+
*/
|
|
11
|
+
function toPascalCase(str: string): string {
|
|
12
|
+
return str
|
|
13
|
+
.split(/[-_]/)
|
|
14
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
15
|
+
.join("");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Compute relative import path
|
|
20
|
+
*/
|
|
21
|
+
function computeRelativePath(fromDir: string, toPath: string): string {
|
|
22
|
+
const fromParts = fromDir.replace(/\\/g, "/").split("/");
|
|
23
|
+
const toParts = toPath.replace(/\\/g, "/").split("/");
|
|
24
|
+
|
|
25
|
+
let commonLength = 0;
|
|
26
|
+
while (
|
|
27
|
+
commonLength < fromParts.length &&
|
|
28
|
+
commonLength < toParts.length &&
|
|
29
|
+
fromParts[commonLength] === toParts[commonLength]
|
|
30
|
+
) {
|
|
31
|
+
commonLength++;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const upCount = fromParts.length - commonLength;
|
|
35
|
+
const relativeParts = toParts.slice(commonLength);
|
|
36
|
+
const ups = Array(upCount).fill("..");
|
|
37
|
+
|
|
38
|
+
let result = [...ups, ...relativeParts].join("/");
|
|
39
|
+
result = result.replace(/\.ts$/, "");
|
|
40
|
+
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Generate type glue code for a route with contract
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```typescript
|
|
49
|
+
* // generated/types/users.types.ts
|
|
50
|
+
* import type { InferContract, InferQuery, InferBody, InferResponse } from "@mandujs/core";
|
|
51
|
+
* import contract from "../../spec/contracts/users.contract";
|
|
52
|
+
*
|
|
53
|
+
* export type UsersContract = InferContract<typeof contract>;
|
|
54
|
+
*
|
|
55
|
+
* // Request types
|
|
56
|
+
* export type UsersGetQuery = InferQuery<typeof contract, "GET">;
|
|
57
|
+
* export type UsersPostBody = InferBody<typeof contract, "POST">;
|
|
58
|
+
*
|
|
59
|
+
* // Response types
|
|
60
|
+
* export type UsersResponse200 = InferResponse<typeof contract, 200>;
|
|
61
|
+
* export type UsersResponse201 = InferResponse<typeof contract, 201>;
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
export function generateContractTypeGlue(
|
|
65
|
+
route: RouteSpec,
|
|
66
|
+
typesDir: string = "apps/server/generated/types"
|
|
67
|
+
): string {
|
|
68
|
+
if (!route.contractModule) {
|
|
69
|
+
return "";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const pascalName = toPascalCase(route.id);
|
|
73
|
+
const contractImportPath = computeRelativePath(typesDir, route.contractModule);
|
|
74
|
+
|
|
75
|
+
return `// Generated by Mandu - DO NOT EDIT DIRECTLY
|
|
76
|
+
// Type glue for route: ${route.id}
|
|
77
|
+
// Contract: ${route.contractModule}
|
|
78
|
+
|
|
79
|
+
import type { InferContract, InferQuery, InferBody, InferParams, InferResponse } from "@mandujs/core";
|
|
80
|
+
import contract from "${contractImportPath}";
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Full contract type for ${route.id}
|
|
84
|
+
*/
|
|
85
|
+
export type ${pascalName}Contract = InferContract<typeof contract>;
|
|
86
|
+
|
|
87
|
+
// ============================================
|
|
88
|
+
// Request Types
|
|
89
|
+
// ============================================
|
|
90
|
+
|
|
91
|
+
/** GET query parameters */
|
|
92
|
+
export type ${pascalName}GetQuery = InferQuery<typeof contract, "GET">;
|
|
93
|
+
|
|
94
|
+
/** POST request body */
|
|
95
|
+
export type ${pascalName}PostBody = InferBody<typeof contract, "POST">;
|
|
96
|
+
|
|
97
|
+
/** PUT request body */
|
|
98
|
+
export type ${pascalName}PutBody = InferBody<typeof contract, "PUT">;
|
|
99
|
+
|
|
100
|
+
/** PATCH request body */
|
|
101
|
+
export type ${pascalName}PatchBody = InferBody<typeof contract, "PATCH">;
|
|
102
|
+
|
|
103
|
+
/** DELETE query parameters */
|
|
104
|
+
export type ${pascalName}DeleteQuery = InferQuery<typeof contract, "DELETE">;
|
|
105
|
+
|
|
106
|
+
/** Path parameters (if any) */
|
|
107
|
+
export type ${pascalName}Params = InferParams<typeof contract, "GET">;
|
|
108
|
+
|
|
109
|
+
// ============================================
|
|
110
|
+
// Response Types
|
|
111
|
+
// ============================================
|
|
112
|
+
|
|
113
|
+
/** 200 OK response */
|
|
114
|
+
export type ${pascalName}Response200 = InferResponse<typeof contract, 200>;
|
|
115
|
+
|
|
116
|
+
/** 201 Created response */
|
|
117
|
+
export type ${pascalName}Response201 = InferResponse<typeof contract, 201>;
|
|
118
|
+
|
|
119
|
+
/** 204 No Content response */
|
|
120
|
+
export type ${pascalName}Response204 = InferResponse<typeof contract, 204>;
|
|
121
|
+
|
|
122
|
+
/** 400 Bad Request response */
|
|
123
|
+
export type ${pascalName}Response400 = InferResponse<typeof contract, 400>;
|
|
124
|
+
|
|
125
|
+
/** 404 Not Found response */
|
|
126
|
+
export type ${pascalName}Response404 = InferResponse<typeof contract, 404>;
|
|
127
|
+
|
|
128
|
+
// Re-export contract for runtime use
|
|
129
|
+
export { contract };
|
|
130
|
+
`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Generate contract template for a route
|
|
135
|
+
*
|
|
136
|
+
* @example
|
|
137
|
+
* ```typescript
|
|
138
|
+
* // spec/contracts/users.contract.ts
|
|
139
|
+
* import { z } from "zod";
|
|
140
|
+
* import { Mandu } from "@mandujs/core";
|
|
141
|
+
*
|
|
142
|
+
* export default Mandu.contract({
|
|
143
|
+
* description: "Users API",
|
|
144
|
+
* tags: ["users"],
|
|
145
|
+
* request: {
|
|
146
|
+
* GET: { query: z.object({ page: z.coerce.number().default(1) }) },
|
|
147
|
+
* POST: { body: z.object({ name: z.string() }) },
|
|
148
|
+
* },
|
|
149
|
+
* response: {
|
|
150
|
+
* 200: z.object({ data: z.array(z.unknown()) }),
|
|
151
|
+
* 201: z.object({ data: z.unknown() }),
|
|
152
|
+
* 400: z.object({ error: z.string() }),
|
|
153
|
+
* },
|
|
154
|
+
* });
|
|
155
|
+
* ```
|
|
156
|
+
*/
|
|
157
|
+
export function generateContractTemplate(route: RouteSpec): string {
|
|
158
|
+
const methods = route.methods || ["GET"];
|
|
159
|
+
const hasGet = methods.includes("GET");
|
|
160
|
+
const hasPost = methods.includes("POST");
|
|
161
|
+
const hasPut = methods.includes("PUT");
|
|
162
|
+
const hasPatch = methods.includes("PATCH");
|
|
163
|
+
const hasDelete = methods.includes("DELETE");
|
|
164
|
+
|
|
165
|
+
const requestParts: string[] = [];
|
|
166
|
+
|
|
167
|
+
if (hasGet) {
|
|
168
|
+
requestParts.push(` GET: {
|
|
169
|
+
query: z.object({
|
|
170
|
+
page: z.coerce.number().int().min(1).default(1),
|
|
171
|
+
limit: z.coerce.number().int().min(1).max(100).default(10),
|
|
172
|
+
}),
|
|
173
|
+
}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (hasPost) {
|
|
177
|
+
requestParts.push(` POST: {
|
|
178
|
+
body: z.object({
|
|
179
|
+
// TODO: Define your request body schema
|
|
180
|
+
name: z.string().min(1),
|
|
181
|
+
}),
|
|
182
|
+
}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (hasPut) {
|
|
186
|
+
requestParts.push(` PUT: {
|
|
187
|
+
body: z.object({
|
|
188
|
+
// TODO: Define your request body schema
|
|
189
|
+
name: z.string().min(1),
|
|
190
|
+
}),
|
|
191
|
+
}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (hasPatch) {
|
|
195
|
+
requestParts.push(` PATCH: {
|
|
196
|
+
body: z.object({
|
|
197
|
+
// TODO: Define your partial update schema
|
|
198
|
+
name: z.string().min(1).optional(),
|
|
199
|
+
}),
|
|
200
|
+
}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (hasDelete) {
|
|
204
|
+
requestParts.push(` DELETE: {
|
|
205
|
+
// Usually no body for DELETE
|
|
206
|
+
}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return `// 📜 Mandu Contract - ${route.id}
|
|
210
|
+
// Pattern: ${route.pattern}
|
|
211
|
+
// 이 파일에서 API 스키마를 정의하세요.
|
|
212
|
+
|
|
213
|
+
import { z } from "zod";
|
|
214
|
+
import { Mandu } from "@mandujs/core";
|
|
215
|
+
|
|
216
|
+
// ============================================
|
|
217
|
+
// 🥟 Schema Definitions
|
|
218
|
+
// ============================================
|
|
219
|
+
|
|
220
|
+
// TODO: Define your data schemas here
|
|
221
|
+
// const ItemSchema = z.object({
|
|
222
|
+
// id: z.string().uuid(),
|
|
223
|
+
// name: z.string(),
|
|
224
|
+
// createdAt: z.string().datetime(),
|
|
225
|
+
// });
|
|
226
|
+
|
|
227
|
+
// ============================================
|
|
228
|
+
// 📜 Contract Definition
|
|
229
|
+
// ============================================
|
|
230
|
+
|
|
231
|
+
export default Mandu.contract({
|
|
232
|
+
description: "${toPascalCase(route.id)} API",
|
|
233
|
+
tags: ["${route.id}"],
|
|
234
|
+
|
|
235
|
+
request: {
|
|
236
|
+
${requestParts.join(",\n\n")}
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
response: {
|
|
240
|
+
200: z.object({
|
|
241
|
+
data: z.unknown(),
|
|
242
|
+
// TODO: Define your success response schema
|
|
243
|
+
}),
|
|
244
|
+
${hasPost || hasPut ? `201: z.object({
|
|
245
|
+
data: z.unknown(),
|
|
246
|
+
// TODO: Define your created response schema
|
|
247
|
+
}),
|
|
248
|
+
` : ""}400: z.object({
|
|
249
|
+
error: z.string(),
|
|
250
|
+
details: z.array(z.object({
|
|
251
|
+
type: z.string(),
|
|
252
|
+
issues: z.array(z.object({
|
|
253
|
+
path: z.string(),
|
|
254
|
+
message: z.string(),
|
|
255
|
+
})),
|
|
256
|
+
})).optional(),
|
|
257
|
+
}),
|
|
258
|
+
404: z.object({
|
|
259
|
+
error: z.string(),
|
|
260
|
+
}),
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// 💡 Contract 사용법:
|
|
265
|
+
// 1. 위의 스키마를 실제 데이터 구조에 맞게 정의하세요
|
|
266
|
+
// 2. mandu generate를 실행하면 타입이 자동으로 Slot에 연결됩니다
|
|
267
|
+
// 3. OpenAPI 문서가 자동으로 생성됩니다
|
|
268
|
+
`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Generate index file that exports all contract types
|
|
273
|
+
*/
|
|
274
|
+
export function generateContractTypesIndex(routeIds: string[]): string {
|
|
275
|
+
const exports = routeIds.map((id) => {
|
|
276
|
+
const fileName = `${id}.types`;
|
|
277
|
+
return `export * from "./${fileName}";`;
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
return `// Generated by Mandu - DO NOT EDIT DIRECTLY
|
|
281
|
+
// Contract type exports
|
|
282
|
+
|
|
283
|
+
${exports.join("\n")}
|
|
284
|
+
`;
|
|
285
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { RoutesManifest, RouteSpec } from "../spec/schema";
|
|
2
2
|
import { generateApiHandler, generatePageComponent, generateSlotLogic } from "./templates";
|
|
3
|
+
import { generateContractTypeGlue, generateContractTemplate, generateContractTypesIndex } from "./contract-glue";
|
|
3
4
|
import { computeHash } from "../spec/lock";
|
|
4
5
|
import path from "path";
|
|
5
6
|
import fs from "fs/promises";
|
|
@@ -51,6 +52,16 @@ export interface SlotMapping {
|
|
|
51
52
|
slotPath: string;
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Contract 파일 매핑 정보
|
|
57
|
+
*/
|
|
58
|
+
export interface ContractMapping {
|
|
59
|
+
/** Contract 파일 경로 */
|
|
60
|
+
contractPath: string;
|
|
61
|
+
/** Type glue 파일 경로 */
|
|
62
|
+
typeGluePath: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
54
65
|
/**
|
|
55
66
|
* Generated 파일 엔트리
|
|
56
67
|
*/
|
|
@@ -63,6 +74,8 @@ export interface GeneratedFileEntry {
|
|
|
63
74
|
specLocation: SpecLocation;
|
|
64
75
|
/** Slot 매핑 (있는 경우) */
|
|
65
76
|
slotMapping?: SlotMapping;
|
|
77
|
+
/** Contract 매핑 (있는 경우) */
|
|
78
|
+
contractMapping?: ContractMapping;
|
|
66
79
|
}
|
|
67
80
|
|
|
68
81
|
/**
|
|
@@ -98,6 +111,15 @@ async function getExistingFiles(dir: string): Promise<string[]> {
|
|
|
98
111
|
}
|
|
99
112
|
}
|
|
100
113
|
|
|
114
|
+
async function getTypeFiles(dir: string): Promise<string[]> {
|
|
115
|
+
try {
|
|
116
|
+
const files = await fs.readdir(dir);
|
|
117
|
+
return files.filter((f) => f.endsWith(".types.ts") || f === "index.ts");
|
|
118
|
+
} catch {
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
101
123
|
export async function generateRoutes(
|
|
102
124
|
manifest: RoutesManifest,
|
|
103
125
|
rootDir: string
|
|
@@ -112,10 +134,12 @@ export async function generateRoutes(
|
|
|
112
134
|
|
|
113
135
|
const serverRoutesDir = path.join(rootDir, "apps/server/generated/routes");
|
|
114
136
|
const webRoutesDir = path.join(rootDir, "apps/web/generated/routes");
|
|
137
|
+
const typesDir = path.join(rootDir, "apps/server/generated/types");
|
|
115
138
|
const mapDir = path.join(rootDir, "packages/core/map");
|
|
116
139
|
|
|
117
140
|
await ensureDir(serverRoutesDir);
|
|
118
141
|
await ensureDir(webRoutesDir);
|
|
142
|
+
await ensureDir(typesDir);
|
|
119
143
|
await ensureDir(mapDir);
|
|
120
144
|
|
|
121
145
|
const generatedMap: GeneratedMap = {
|
|
@@ -135,6 +159,8 @@ export async function generateRoutes(
|
|
|
135
159
|
|
|
136
160
|
const expectedServerFiles = new Set<string>();
|
|
137
161
|
const expectedWebFiles = new Set<string>();
|
|
162
|
+
const expectedTypeFiles = new Set<string>();
|
|
163
|
+
const routesWithContracts: string[] = [];
|
|
138
164
|
|
|
139
165
|
for (let routeIndex = 0; routeIndex < manifest.routes.length; routeIndex++) {
|
|
140
166
|
const route = manifest.routes[routeIndex];
|
|
@@ -152,6 +178,9 @@ export async function generateRoutes(
|
|
|
152
178
|
? { slotPath: route.slotModule }
|
|
153
179
|
: undefined;
|
|
154
180
|
|
|
181
|
+
// Contract 매핑 정보 (있는 경우)
|
|
182
|
+
let contractMapping: ContractMapping | undefined;
|
|
183
|
+
|
|
155
184
|
// Server handler
|
|
156
185
|
const serverFileName = `${route.id}.route.ts`;
|
|
157
186
|
const serverFilePath = path.join(serverRoutesDir, serverFileName);
|
|
@@ -161,11 +190,46 @@ export async function generateRoutes(
|
|
|
161
190
|
await Bun.write(serverFilePath, handlerContent);
|
|
162
191
|
result.created.push(serverFilePath);
|
|
163
192
|
|
|
193
|
+
// Contract file (only if contractModule is specified)
|
|
194
|
+
if (route.contractModule) {
|
|
195
|
+
const contractFilePath = path.join(rootDir, route.contractModule);
|
|
196
|
+
const contractDir = path.dirname(contractFilePath);
|
|
197
|
+
|
|
198
|
+
await ensureDir(contractDir);
|
|
199
|
+
|
|
200
|
+
// contract 파일이 이미 존재하면 덮어쓰지 않음 (사용자 코드 보존)
|
|
201
|
+
const contractExists = await fileExists(contractFilePath);
|
|
202
|
+
if (!contractExists) {
|
|
203
|
+
const contractContent = generateContractTemplate(route);
|
|
204
|
+
await Bun.write(contractFilePath, contractContent);
|
|
205
|
+
result.created.push(contractFilePath);
|
|
206
|
+
} else {
|
|
207
|
+
result.skipped.push(contractFilePath);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Generate type glue
|
|
211
|
+
const typeFileName = `${route.id}.types.ts`;
|
|
212
|
+
const typeFilePath = path.join(typesDir, typeFileName);
|
|
213
|
+
expectedTypeFiles.add(typeFileName);
|
|
214
|
+
|
|
215
|
+
const typeGlueContent = generateContractTypeGlue(route, "apps/server/generated/types");
|
|
216
|
+
await Bun.write(typeFilePath, typeGlueContent);
|
|
217
|
+
result.created.push(typeFilePath);
|
|
218
|
+
|
|
219
|
+
contractMapping = {
|
|
220
|
+
contractPath: route.contractModule,
|
|
221
|
+
typeGluePath: `apps/server/generated/types/${typeFileName}`,
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
routesWithContracts.push(route.id);
|
|
225
|
+
}
|
|
226
|
+
|
|
164
227
|
generatedMap.files[`apps/server/generated/routes/${serverFileName}`] = {
|
|
165
228
|
routeId: route.id,
|
|
166
229
|
kind: route.kind as "api" | "page",
|
|
167
230
|
specLocation,
|
|
168
231
|
slotMapping,
|
|
232
|
+
contractMapping,
|
|
169
233
|
};
|
|
170
234
|
|
|
171
235
|
// Slot file (only if slotModule is specified)
|
|
@@ -201,6 +265,7 @@ export async function generateRoutes(
|
|
|
201
265
|
kind: route.kind,
|
|
202
266
|
specLocation,
|
|
203
267
|
slotMapping,
|
|
268
|
+
contractMapping,
|
|
204
269
|
};
|
|
205
270
|
}
|
|
206
271
|
} catch (error) {
|
|
@@ -211,6 +276,14 @@ export async function generateRoutes(
|
|
|
211
276
|
}
|
|
212
277
|
}
|
|
213
278
|
|
|
279
|
+
// Generate types index if there are contracts
|
|
280
|
+
if (routesWithContracts.length > 0) {
|
|
281
|
+
const typesIndexContent = generateContractTypesIndex(routesWithContracts);
|
|
282
|
+
const typesIndexPath = path.join(typesDir, "index.ts");
|
|
283
|
+
await Bun.write(typesIndexPath, typesIndexContent);
|
|
284
|
+
result.created.push(typesIndexPath);
|
|
285
|
+
}
|
|
286
|
+
|
|
214
287
|
// Clean up stale files
|
|
215
288
|
const existingServerFiles = await getExistingFiles(serverRoutesDir);
|
|
216
289
|
for (const file of existingServerFiles) {
|
|
@@ -230,6 +303,16 @@ export async function generateRoutes(
|
|
|
230
303
|
}
|
|
231
304
|
}
|
|
232
305
|
|
|
306
|
+
// Clean up stale type files
|
|
307
|
+
const existingTypeFiles = await getTypeFiles(typesDir);
|
|
308
|
+
for (const file of existingTypeFiles) {
|
|
309
|
+
if (!expectedTypeFiles.has(file) && file !== "index.ts") {
|
|
310
|
+
const filePath = path.join(typesDir, file);
|
|
311
|
+
await fs.unlink(filePath);
|
|
312
|
+
result.deleted.push(filePath);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
233
316
|
// Write generated map
|
|
234
317
|
const mapPath = path.join(mapDir, "generated.map.json");
|
|
235
318
|
await Bun.write(mapPath, JSON.stringify(generatedMap, null, 2));
|
package/src/generator/index.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import type { RouteSpec } from "../spec/schema";
|
|
2
2
|
|
|
3
3
|
export function generateApiHandler(route: RouteSpec): string {
|
|
4
|
-
// slotModule이 있으면
|
|
4
|
+
// contractModule + slotModule이 있으면 contract 검증 버전 생성
|
|
5
|
+
if (route.contractModule && route.slotModule) {
|
|
6
|
+
return generateApiHandlerWithContract(route);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// slotModule만 있으면 slot을 호출하는 버전 생성
|
|
5
10
|
if (route.slotModule) {
|
|
6
11
|
return generateApiHandlerWithSlot(route);
|
|
7
12
|
}
|
|
@@ -43,6 +48,68 @@ export default async function handler(
|
|
|
43
48
|
`;
|
|
44
49
|
}
|
|
45
50
|
|
|
51
|
+
export function generateApiHandlerWithContract(route: RouteSpec): string {
|
|
52
|
+
const slotImportPath = computeSlotImportPath(route.slotModule!, "apps/server/generated/routes");
|
|
53
|
+
const contractImportPath = computeSlotImportPath(route.contractModule!, "apps/server/generated/routes");
|
|
54
|
+
|
|
55
|
+
return `// Generated by Mandu - DO NOT EDIT DIRECTLY
|
|
56
|
+
// Route ID: ${route.id}
|
|
57
|
+
// Pattern: ${route.pattern}
|
|
58
|
+
// Contract Module: ${route.contractModule}
|
|
59
|
+
// Slot Module: ${route.slotModule}
|
|
60
|
+
|
|
61
|
+
import type { Request } from "bun";
|
|
62
|
+
import { ContractValidator, formatValidationErrors } from "@mandujs/core";
|
|
63
|
+
import contract from "${contractImportPath}";
|
|
64
|
+
import filling from "${slotImportPath}";
|
|
65
|
+
|
|
66
|
+
const validator = new ContractValidator(contract);
|
|
67
|
+
const isDev = process.env.NODE_ENV !== "production";
|
|
68
|
+
|
|
69
|
+
export default async function handler(
|
|
70
|
+
req: Request,
|
|
71
|
+
params: Record<string, string>
|
|
72
|
+
): Promise<Response> {
|
|
73
|
+
const method = req.method;
|
|
74
|
+
|
|
75
|
+
// 1. Request Validation
|
|
76
|
+
const reqValidation = await validator.validateRequest(req, method, params);
|
|
77
|
+
if (!reqValidation.success) {
|
|
78
|
+
return Response.json(
|
|
79
|
+
formatValidationErrors(reqValidation.errors!),
|
|
80
|
+
{ status: 400 }
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 2. Execute Filling
|
|
85
|
+
const response = await filling.handle(req, params, {
|
|
86
|
+
routeId: "${route.id}",
|
|
87
|
+
pattern: "${route.pattern}",
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// 3. Response Validation (dev only)
|
|
91
|
+
if (isDev && response.headers.get("content-type")?.includes("application/json")) {
|
|
92
|
+
try {
|
|
93
|
+
const cloned = response.clone();
|
|
94
|
+
const body = await cloned.json();
|
|
95
|
+
const resValidation = validator.validateResponse(body, response.status);
|
|
96
|
+
if (!resValidation.success) {
|
|
97
|
+
console.warn(
|
|
98
|
+
"\\x1b[33m[Mandu] Contract violation in response:\\x1b[0m",
|
|
99
|
+
"${route.id}",
|
|
100
|
+
resValidation.errors
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
} catch {
|
|
104
|
+
// Ignore JSON parse errors for non-JSON responses
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return response;
|
|
109
|
+
}
|
|
110
|
+
`;
|
|
111
|
+
}
|
|
112
|
+
|
|
46
113
|
export function generateSlotLogic(route: RouteSpec): string {
|
|
47
114
|
return `// 🥟 Mandu Filling - ${route.id}
|
|
48
115
|
// Pattern: ${route.pattern}
|
|
@@ -122,7 +189,7 @@ function computeSlotImportPath(slotModule: string, fromDir: string): string {
|
|
|
122
189
|
}
|
|
123
190
|
|
|
124
191
|
export function generatePageComponent(route: RouteSpec): string {
|
|
125
|
-
const pageName =
|
|
192
|
+
const pageName = toPascalCase(route.id);
|
|
126
193
|
return `// Generated by Mandu - DO NOT EDIT DIRECTLY
|
|
127
194
|
// Route ID: ${route.id}
|
|
128
195
|
// Pattern: ${route.pattern}
|
|
@@ -143,6 +210,14 @@ export default function ${pageName}Page({ params }: Props): React.ReactElement {
|
|
|
143
210
|
`;
|
|
144
211
|
}
|
|
145
212
|
|
|
146
|
-
|
|
147
|
-
|
|
213
|
+
/**
|
|
214
|
+
* Convert string to PascalCase (handles kebab-case, snake_case)
|
|
215
|
+
* "todo-page" → "TodoPage"
|
|
216
|
+
* "user_profile" → "UserProfile"
|
|
217
|
+
*/
|
|
218
|
+
function toPascalCase(str: string): string {
|
|
219
|
+
return str
|
|
220
|
+
.split(/[-_]/)
|
|
221
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
222
|
+
.join("");
|
|
148
223
|
}
|
package/src/guard/check.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { GUARD_RULES, FORBIDDEN_IMPORTS, type GuardViolation } from "./rules";
|
|
2
2
|
import { verifyLock, computeHash } from "../spec/lock";
|
|
3
|
+
import { runContractGuardCheck } from "./contract-guard";
|
|
3
4
|
import type { RoutesManifest } from "../spec/schema";
|
|
4
5
|
import type { GeneratedMap } from "../generator/generate";
|
|
5
6
|
import path from "path";
|
|
@@ -248,6 +249,10 @@ export async function runGuardCheck(
|
|
|
248
249
|
const slotViolations = await checkSlotFileExists(manifest, rootDir);
|
|
249
250
|
violations.push(...slotViolations);
|
|
250
251
|
|
|
252
|
+
// Rule 6-9: Contract-related checks
|
|
253
|
+
const contractViolations = await runContractGuardCheck(manifest, rootDir);
|
|
254
|
+
violations.push(...contractViolations);
|
|
255
|
+
|
|
251
256
|
return {
|
|
252
257
|
passed: violations.length === 0,
|
|
253
258
|
violations,
|