@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.
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/paths.ts +16 -0
- package/src/resource/__tests__/backward-compat.test.ts +302 -0
- package/src/resource/__tests__/edge-cases.test.ts +514 -0
- package/src/resource/__tests__/fixtures.ts +203 -0
- package/src/resource/__tests__/generator.test.ts +324 -0
- package/src/resource/__tests__/performance.test.ts +311 -0
- package/src/resource/__tests__/schema.test.ts +184 -0
- package/src/resource/generator.ts +277 -0
- package/src/resource/generators/client.ts +199 -0
- package/src/resource/generators/contract.ts +264 -0
- package/src/resource/generators/slot.ts +193 -0
- package/src/resource/generators/types.ts +83 -0
- package/src/resource/index.ts +42 -0
- package/src/resource/parser.ts +139 -0
- package/src/resource/schema.ts +252 -0
- package/src/router/fs-scanner.ts +21 -6
|
@@ -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
|
+
}
|