@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.
@@ -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));
@@ -1,2 +1,3 @@
1
1
  export * from "./generate";
2
2
  export * from "./templates";
3
+ export * from "./contract-glue";
@@ -1,7 +1,12 @@
1
1
  import type { RouteSpec } from "../spec/schema";
2
2
 
3
3
  export function generateApiHandler(route: RouteSpec): string {
4
- // slotModule이 있으면 slot을 호출하는 버전 생성
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 = capitalize(route.id);
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
- function capitalize(str: string): string {
147
- return str.charAt(0).toUpperCase() + str.slice(1);
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
  }
@@ -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,