@mandujs/core 0.17.0 → 0.18.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.
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Resource Schema Tests
3
+ */
4
+
5
+ import { describe, test, expect } from "bun:test";
6
+ import {
7
+ defineResource,
8
+ validateResourceDefinition,
9
+ getPluralName,
10
+ getEnabledEndpoints,
11
+ isFieldRequired,
12
+ getFieldDefault,
13
+ } from "../schema";
14
+ import {
15
+ userResourceFixture,
16
+ postResourceFixture,
17
+ productResourceFixture,
18
+ minimalResourceFixture,
19
+ invalidResourceFixtures,
20
+ } from "./fixtures";
21
+
22
+ describe("defineResource", () => {
23
+ test("should accept valid resource definition", () => {
24
+ const result = defineResource(userResourceFixture);
25
+
26
+ expect(result.name).toBe("user");
27
+ expect(result.fields).toBeDefined();
28
+ expect(result.options).toBeDefined();
29
+ });
30
+
31
+ test("should apply default options", () => {
32
+ const result = defineResource(minimalResourceFixture);
33
+
34
+ expect(result.options?.autoPlural).toBe(true);
35
+ expect(result.options?.endpoints).toBeDefined();
36
+ expect(result.options?.endpoints?.list).toBe(true);
37
+ expect(result.options?.endpoints?.get).toBe(true);
38
+ expect(result.options?.endpoints?.create).toBe(true);
39
+ expect(result.options?.endpoints?.update).toBe(true);
40
+ expect(result.options?.endpoints?.delete).toBe(true);
41
+ });
42
+
43
+ test("should merge custom options with defaults", () => {
44
+ const result = defineResource(productResourceFixture);
45
+
46
+ expect(result.options?.endpoints?.list).toBe(true);
47
+ expect(result.options?.endpoints?.update).toBe(false);
48
+ expect(result.options?.endpoints?.delete).toBe(false);
49
+ });
50
+
51
+ test("should apply default pagination settings", () => {
52
+ const result = defineResource(minimalResourceFixture);
53
+
54
+ expect(result.options?.pagination?.defaultLimit).toBe(10);
55
+ expect(result.options?.pagination?.maxLimit).toBe(100);
56
+ });
57
+
58
+ test("should preserve custom pagination settings", () => {
59
+ const result = defineResource(userResourceFixture);
60
+
61
+ expect(result.options?.pagination?.defaultLimit).toBe(20);
62
+ expect(result.options?.pagination?.maxLimit).toBe(100);
63
+ });
64
+ });
65
+
66
+ describe("validateResourceDefinition", () => {
67
+ test("should validate correct resource definition", () => {
68
+ expect(() => {
69
+ validateResourceDefinition(userResourceFixture);
70
+ }).not.toThrow();
71
+ });
72
+
73
+ test("should throw on missing name", () => {
74
+ expect(() => {
75
+ validateResourceDefinition(invalidResourceFixtures.noName);
76
+ }).toThrow("Resource name is required");
77
+ });
78
+
79
+ test("should throw on invalid name format", () => {
80
+ expect(() => {
81
+ validateResourceDefinition(invalidResourceFixtures.invalidName);
82
+ }).toThrow(/Invalid resource name/);
83
+ });
84
+
85
+ test("should throw on empty fields", () => {
86
+ expect(() => {
87
+ validateResourceDefinition(invalidResourceFixtures.noFields);
88
+ }).toThrow(/must have at least one field/);
89
+ });
90
+
91
+ test("should throw on invalid field name", () => {
92
+ expect(() => {
93
+ validateResourceDefinition(invalidResourceFixtures.invalidFieldName);
94
+ }).toThrow(/Invalid field name/);
95
+ });
96
+
97
+ test("should throw on invalid field type", () => {
98
+ expect(() => {
99
+ validateResourceDefinition(invalidResourceFixtures.invalidFieldType);
100
+ }).toThrow(/Invalid field type/);
101
+ });
102
+
103
+ test("should throw on array without items", () => {
104
+ expect(() => {
105
+ validateResourceDefinition(invalidResourceFixtures.arrayWithoutItems);
106
+ }).toThrow(/missing "items" property/);
107
+ });
108
+ });
109
+
110
+ describe("getPluralName", () => {
111
+ test("should add 's' for simple pluralization", () => {
112
+ const result = getPluralName(userResourceFixture);
113
+ expect(result).toBe("users");
114
+ });
115
+
116
+ test("should use custom plural name if provided", () => {
117
+ const result = getPluralName(productResourceFixture);
118
+ expect(result).toBe("inventory");
119
+ });
120
+
121
+ test("should respect autoPlural: false", () => {
122
+ const definition = {
123
+ ...minimalResourceFixture,
124
+ options: { autoPlural: false },
125
+ };
126
+ const result = getPluralName(definition);
127
+ expect(result).toBe("item");
128
+ });
129
+ });
130
+
131
+ describe("getEnabledEndpoints", () => {
132
+ test("should return all enabled endpoints", () => {
133
+ const result = getEnabledEndpoints(userResourceFixture);
134
+ expect(result).toEqual(["list", "get", "create", "update", "delete"]);
135
+ });
136
+
137
+ test("should return only enabled endpoints", () => {
138
+ const result = getEnabledEndpoints(productResourceFixture);
139
+ expect(result).toEqual(["list", "get", "create"]);
140
+ expect(result).not.toContain("update");
141
+ expect(result).not.toContain("delete");
142
+ });
143
+
144
+ test("should return all endpoints by default", () => {
145
+ const result = getEnabledEndpoints(minimalResourceFixture);
146
+ expect(result).toEqual(["list", "get", "create", "update", "delete"]);
147
+ });
148
+ });
149
+
150
+ describe("isFieldRequired", () => {
151
+ test("should return true for required fields", () => {
152
+ const field = userResourceFixture.fields.email;
153
+ expect(isFieldRequired(field)).toBe(true);
154
+ });
155
+
156
+ test("should return false for optional fields", () => {
157
+ const field = userResourceFixture.fields.age;
158
+ expect(isFieldRequired(field)).toBe(false);
159
+ });
160
+
161
+ test("should return false by default", () => {
162
+ const field = { type: "string" as const };
163
+ expect(isFieldRequired(field)).toBe(false);
164
+ });
165
+ });
166
+
167
+ describe("getFieldDefault", () => {
168
+ test("should return default value if provided", () => {
169
+ const field = userResourceFixture.fields.isActive;
170
+ expect(getFieldDefault(field)).toBe(true);
171
+ });
172
+
173
+ test("should return undefined if no default", () => {
174
+ const field = userResourceFixture.fields.email;
175
+ expect(getFieldDefault(field)).toBeUndefined();
176
+ });
177
+
178
+ test("should handle array default", () => {
179
+ const field = postResourceFixture.fields.tags;
180
+ const defaultValue = getFieldDefault(field);
181
+ expect(Array.isArray(defaultValue)).toBe(true);
182
+ expect(defaultValue).toEqual([]);
183
+ });
184
+ });
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Resource Generator
3
+ * Main orchestrator for generating resource artifacts
4
+ */
5
+
6
+ import type { ParsedResource } from "./parser";
7
+ import { generateResourceContract } from "./generators/contract";
8
+ import { generateResourceTypes } from "./generators/types";
9
+ import { generateResourceSlot } from "./generators/slot";
10
+ import { generateResourceClient } from "./generators/client";
11
+ import { resolveGeneratedPaths } from "../paths";
12
+ import path from "path";
13
+ import fs from "fs/promises";
14
+
15
+ // ============================================
16
+ // Generator Options
17
+ // ============================================
18
+
19
+ export interface GeneratorOptions {
20
+ /** 프로젝트 루트 디렉토리 */
21
+ rootDir: string;
22
+ /** 기존 슬롯 덮어쓰기 (기본: false) */
23
+ force?: boolean;
24
+ /** 특정 파일만 생성 */
25
+ only?: ("contract" | "types" | "slot" | "client")[];
26
+ }
27
+
28
+ // ============================================
29
+ // Generator Result
30
+ // ============================================
31
+
32
+ export interface GeneratorResult {
33
+ success: boolean;
34
+ created: string[];
35
+ skipped: string[];
36
+ errors: string[];
37
+ }
38
+
39
+ // ============================================
40
+ // File Utilities
41
+ // ============================================
42
+
43
+ async function fileExists(filePath: string): Promise<boolean> {
44
+ try {
45
+ await fs.access(filePath);
46
+ return true;
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ async function ensureDir(dirPath: string): Promise<void> {
53
+ try {
54
+ await fs.mkdir(dirPath, { recursive: true });
55
+ } catch (error) {
56
+ // Ignore if exists
57
+ }
58
+ }
59
+
60
+ // ============================================
61
+ // Generate Resource Artifacts
62
+ // ============================================
63
+
64
+ /**
65
+ * Generate all artifacts for a resource
66
+ *
67
+ * @param parsed - Parsed resource schema
68
+ * @param options - Generator options
69
+ * @returns Generation result
70
+ *
71
+ * @example
72
+ * ```typescript
73
+ * const parsed = await parseResourceSchema("/path/to/user.resource.ts");
74
+ * const result = await generateResourceArtifacts(parsed, {
75
+ * rootDir: process.cwd(),
76
+ * force: false,
77
+ * });
78
+ * ```
79
+ */
80
+ export async function generateResourceArtifacts(
81
+ parsed: ParsedResource,
82
+ options: GeneratorOptions
83
+ ): Promise<GeneratorResult> {
84
+ const result: GeneratorResult = {
85
+ success: true,
86
+ created: [],
87
+ skipped: [],
88
+ errors: [],
89
+ };
90
+
91
+ const { definition, resourceName } = parsed;
92
+ const { rootDir, force = false, only } = options;
93
+
94
+ const paths = resolveGeneratedPaths(rootDir);
95
+
96
+ try {
97
+ // 1. Generate Contract (always regenerate)
98
+ if (!only || only.includes("contract")) {
99
+ await generateContract(definition, resourceName, paths.resourceContractsDir, result);
100
+ }
101
+
102
+ // 2. Generate Types (always regenerate)
103
+ if (!only || only.includes("types")) {
104
+ await generateTypes(definition, resourceName, paths.resourceTypesDir, result);
105
+ }
106
+
107
+ // 3. Generate Slot (PRESERVE if exists unless --force)
108
+ if (!only || only.includes("slot")) {
109
+ await generateSlot(definition, resourceName, paths.resourceSlotsDir, force, result);
110
+ }
111
+
112
+ // 4. Generate Client (always regenerate)
113
+ if (!only || only.includes("client")) {
114
+ await generateClient(definition, resourceName, paths.resourceClientDir, result);
115
+ }
116
+ } catch (error) {
117
+ result.success = false;
118
+ result.errors.push(
119
+ `Failed to generate resource "${resourceName}": ${
120
+ error instanceof Error ? error.message : String(error)
121
+ }`
122
+ );
123
+ }
124
+
125
+ return result;
126
+ }
127
+
128
+ /**
129
+ * Generate contract file
130
+ */
131
+ async function generateContract(
132
+ definition: any,
133
+ resourceName: string,
134
+ contractsDir: string,
135
+ result: GeneratorResult
136
+ ): Promise<void> {
137
+ await ensureDir(contractsDir);
138
+
139
+ const contractPath = path.join(contractsDir, `${resourceName}.contract.ts`);
140
+ const contractContent = generateResourceContract(definition);
141
+
142
+ await Bun.write(contractPath, contractContent);
143
+ result.created.push(contractPath);
144
+ }
145
+
146
+ /**
147
+ * Generate types file
148
+ */
149
+ async function generateTypes(
150
+ definition: any,
151
+ resourceName: string,
152
+ typesDir: string,
153
+ result: GeneratorResult
154
+ ): Promise<void> {
155
+ await ensureDir(typesDir);
156
+
157
+ const typesPath = path.join(typesDir, `${resourceName}.types.ts`);
158
+ const typesContent = generateResourceTypes(definition);
159
+
160
+ await Bun.write(typesPath, typesContent);
161
+ result.created.push(typesPath);
162
+ }
163
+
164
+ /**
165
+ * Generate slot file (PRESERVE if exists!)
166
+ */
167
+ async function generateSlot(
168
+ definition: any,
169
+ resourceName: string,
170
+ slotsDir: string,
171
+ force: boolean,
172
+ result: GeneratorResult
173
+ ): Promise<void> {
174
+ await ensureDir(slotsDir);
175
+
176
+ const slotPath = path.join(slotsDir, `${resourceName}.slot.ts`);
177
+ const slotExists = await fileExists(slotPath);
178
+
179
+ // CRITICAL: Slot preservation logic
180
+ if (!slotExists || force) {
181
+ const slotContent = generateResourceSlot(definition);
182
+ await Bun.write(slotPath, slotContent);
183
+ result.created.push(slotPath);
184
+
185
+ if (slotExists && force) {
186
+ console.log(`⚠️ Overwriting existing slot (--force): ${slotPath}`);
187
+ }
188
+ } else {
189
+ result.skipped.push(slotPath);
190
+ console.log(`✓ Preserving existing slot: ${slotPath}`);
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Generate client file
196
+ */
197
+ async function generateClient(
198
+ definition: any,
199
+ resourceName: string,
200
+ clientDir: string,
201
+ result: GeneratorResult
202
+ ): Promise<void> {
203
+ await ensureDir(clientDir);
204
+
205
+ const clientPath = path.join(clientDir, `${resourceName}.client.ts`);
206
+ const clientContent = generateResourceClient(definition);
207
+
208
+ await Bun.write(clientPath, clientContent);
209
+ result.created.push(clientPath);
210
+ }
211
+
212
+ // ============================================
213
+ // Batch Generation
214
+ // ============================================
215
+
216
+ /**
217
+ * Generate artifacts for multiple resources
218
+ *
219
+ * @param resources - Array of parsed resources
220
+ * @param options - Generator options
221
+ * @returns Combined generation result
222
+ */
223
+ export async function generateResourcesArtifacts(
224
+ resources: ParsedResource[],
225
+ options: GeneratorOptions
226
+ ): Promise<GeneratorResult> {
227
+ const combinedResult: GeneratorResult = {
228
+ success: true,
229
+ created: [],
230
+ skipped: [],
231
+ errors: [],
232
+ };
233
+
234
+ for (const resource of resources) {
235
+ const result = await generateResourceArtifacts(resource, options);
236
+
237
+ combinedResult.created.push(...result.created);
238
+ combinedResult.skipped.push(...result.skipped);
239
+ combinedResult.errors.push(...result.errors);
240
+
241
+ if (!result.success) {
242
+ combinedResult.success = false;
243
+ }
244
+ }
245
+
246
+ return combinedResult;
247
+ }
248
+
249
+ // ============================================
250
+ // Summary Logging
251
+ // ============================================
252
+
253
+ /**
254
+ * Log generation result summary
255
+ */
256
+ export function logGeneratorResult(result: GeneratorResult): void {
257
+ console.log("\n📦 Resource Generation Summary:");
258
+ console.log(` ✅ Created: ${result.created.length} files`);
259
+ console.log(` ⏭️ Skipped: ${result.skipped.length} files`);
260
+
261
+ if (result.errors.length > 0) {
262
+ console.log(` ❌ Errors: ${result.errors.length}`);
263
+ result.errors.forEach((error) => console.error(` - ${error}`));
264
+ }
265
+
266
+ if (result.created.length > 0) {
267
+ console.log("\n Created files:");
268
+ result.created.forEach((file) => console.log(` - ${file}`));
269
+ }
270
+
271
+ if (result.skipped.length > 0) {
272
+ console.log("\n Skipped (preserved):");
273
+ result.skipped.forEach((file) => console.log(` - ${file}`));
274
+ }
275
+
276
+ console.log();
277
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Resource Client Generator
3
+ * Generate type-safe client methods for resource API
4
+ */
5
+
6
+ import type { ResourceDefinition } from "../schema";
7
+ import { getPluralName, getEnabledEndpoints } from "../schema";
8
+
9
+ /**
10
+ * Generate client file for resource
11
+ *
12
+ * @returns Client file content
13
+ */
14
+ export function generateResourceClient(definition: ResourceDefinition): string {
15
+ const resourceName = definition.name;
16
+ const pascalName = toPascalCase(resourceName);
17
+ const pluralName = getPluralName(definition);
18
+ const endpoints = getEnabledEndpoints(definition);
19
+
20
+ // Generate client methods
21
+ const methods = generateClientMethods(definition, endpoints, pascalName, pluralName);
22
+
23
+ return `// 🌐 Mandu Client - ${resourceName} Resource
24
+ // Auto-generated from resource definition
25
+ // DO NOT EDIT - Regenerated on every \`mandu generate\`
26
+
27
+ import type {
28
+ ${pascalName}GetQuery,
29
+ ${pascalName}PostBody,
30
+ ${pascalName}PutBody,
31
+ ${pascalName}Response200,
32
+ ${pascalName}Response201,
33
+ } from "../types/${resourceName}.types";
34
+
35
+ /**
36
+ * ${pascalName} Resource Client
37
+ * Type-safe API client for ${pluralName}
38
+ */
39
+ export class ${pascalName}Client {
40
+ private baseUrl: string;
41
+
42
+ constructor(baseUrl: string = "") {
43
+ this.baseUrl = baseUrl;
44
+ }
45
+
46
+ ${methods}
47
+
48
+ /**
49
+ * Internal fetch wrapper with error handling
50
+ */
51
+ private async fetch<T>(
52
+ path: string,
53
+ options?: RequestInit
54
+ ): Promise<T> {
55
+ const url = \`\${this.baseUrl}\${path}\`;
56
+ const response = await fetch(url, {
57
+ ...options,
58
+ headers: {
59
+ "Content-Type": "application/json",
60
+ ...options?.headers,
61
+ },
62
+ });
63
+
64
+ if (!response.ok) {
65
+ const error = await response.json().catch(() => ({ error: "Unknown error" }));
66
+ throw new Error(error.error || \`HTTP \${response.status}\`);
67
+ }
68
+
69
+ return response.json();
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Create a ${pascalName} client instance
75
+ */
76
+ export function create${pascalName}Client(baseUrl?: string): ${pascalName}Client {
77
+ return new ${pascalName}Client(baseUrl);
78
+ }
79
+ `;
80
+ }
81
+
82
+ /**
83
+ * Generate client methods for enabled endpoints
84
+ */
85
+ function generateClientMethods(
86
+ definition: ResourceDefinition,
87
+ endpoints: string[],
88
+ pascalName: string,
89
+ pluralName: string
90
+ ): string {
91
+ const methods: string[] = [];
92
+
93
+ if (endpoints.includes("list")) {
94
+ methods.push(generateListMethod(pascalName, pluralName));
95
+ }
96
+
97
+ if (endpoints.includes("get")) {
98
+ methods.push(generateGetMethod(pascalName, pluralName));
99
+ }
100
+
101
+ if (endpoints.includes("create")) {
102
+ methods.push(generateCreateMethod(pascalName, pluralName));
103
+ }
104
+
105
+ if (endpoints.includes("update")) {
106
+ methods.push(generateUpdateMethod(pascalName, pluralName));
107
+ }
108
+
109
+ if (endpoints.includes("delete")) {
110
+ methods.push(generateDeleteMethod(pascalName, pluralName));
111
+ }
112
+
113
+ return methods.join("\n\n");
114
+ }
115
+
116
+ /**
117
+ * Generate LIST method
118
+ */
119
+ function generateListMethod(pascalName: string, pluralName: string): string {
120
+ return ` /**
121
+ * List ${pascalName}s with pagination
122
+ */
123
+ async list(query?: ${pascalName}GetQuery): Promise<${pascalName}Response200> {
124
+ const params = new URLSearchParams();
125
+ if (query?.page) params.set("page", String(query.page));
126
+ if (query?.limit) params.set("limit", String(query.limit));
127
+
128
+ const queryString = params.toString();
129
+ const path = queryString ? \`/api/${pluralName}?\${queryString}\` : \`/api/${pluralName}\`;
130
+
131
+ return this.fetch<${pascalName}Response200>(path);
132
+ }`;
133
+ }
134
+
135
+ /**
136
+ * Generate GET method
137
+ */
138
+ function generateGetMethod(pascalName: string, pluralName: string): string {
139
+ return ` /**
140
+ * Get a single ${pascalName} by ID
141
+ */
142
+ async get(id: string): Promise<${pascalName}Response200> {
143
+ return this.fetch<${pascalName}Response200>(\`/api/${pluralName}/\${id}\`);
144
+ }`;
145
+ }
146
+
147
+ /**
148
+ * Generate CREATE method
149
+ */
150
+ function generateCreateMethod(pascalName: string, pluralName: string): string {
151
+ return ` /**
152
+ * Create a new ${pascalName}
153
+ */
154
+ async create(data: ${pascalName}PostBody): Promise<${pascalName}Response201> {
155
+ return this.fetch<${pascalName}Response201>(\`/api/${pluralName}\`, {
156
+ method: "POST",
157
+ body: JSON.stringify(data),
158
+ });
159
+ }`;
160
+ }
161
+
162
+ /**
163
+ * Generate UPDATE method
164
+ */
165
+ function generateUpdateMethod(pascalName: string, pluralName: string): string {
166
+ return ` /**
167
+ * Update an existing ${pascalName}
168
+ */
169
+ async update(id: string, data: ${pascalName}PutBody): Promise<${pascalName}Response200> {
170
+ return this.fetch<${pascalName}Response200>(\`/api/${pluralName}/\${id}\`, {
171
+ method: "PUT",
172
+ body: JSON.stringify(data),
173
+ });
174
+ }`;
175
+ }
176
+
177
+ /**
178
+ * Generate DELETE method
179
+ */
180
+ function generateDeleteMethod(pascalName: string, pluralName: string): string {
181
+ return ` /**
182
+ * Delete a ${pascalName}
183
+ */
184
+ async delete(id: string): Promise<${pascalName}Response200> {
185
+ return this.fetch<${pascalName}Response200>(\`/api/${pluralName}/\${id}\`, {
186
+ method: "DELETE",
187
+ });
188
+ }`;
189
+ }
190
+
191
+ /**
192
+ * Convert string to PascalCase
193
+ */
194
+ function toPascalCase(str: string): string {
195
+ return str
196
+ .split(/[-_]/)
197
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
198
+ .join("");
199
+ }