swallowkit 1.0.0-beta.16 → 1.0.0-beta.18
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/dist/__tests__/fixtures.d.ts.map +1 -1
- package/dist/__tests__/fixtures.js +1 -0
- package/dist/__tests__/fixtures.js.map +1 -1
- package/dist/cli/commands/dev-seeds.js +4 -4
- package/dist/cli/commands/dev-seeds.js.map +1 -1
- package/dist/cli/commands/dev.js +14 -3
- package/dist/cli/commands/dev.js.map +1 -1
- package/dist/cli/commands/init.js +4 -4
- package/dist/cli/commands/scaffold.js +1 -1
- package/dist/cli/commands/scaffold.js.map +1 -1
- package/dist/core/scaffold/functions-generator.d.ts.map +1 -1
- package/dist/core/scaffold/functions-generator.js +341 -85
- package/dist/core/scaffold/functions-generator.js.map +1 -1
- package/dist/core/scaffold/model-parser.d.ts +7 -0
- package/dist/core/scaffold/model-parser.d.ts.map +1 -1
- package/dist/core/scaffold/model-parser.js +23 -0
- package/dist/core/scaffold/model-parser.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/functions-generator.test.ts.snap +694 -0
- package/src/__tests__/fixtures.ts +1 -0
- package/src/__tests__/functions-generator.test.ts +136 -0
- package/src/__tests__/model-parser.test.ts +72 -0
- package/src/cli/commands/dev-seeds.ts +4 -4
- package/src/cli/commands/dev.ts +15 -3
- package/src/cli/commands/init.ts +4 -4
- package/src/cli/commands/scaffold.ts +1 -1
- package/src/core/scaffold/functions-generator.ts +365 -85
- package/src/core/scaffold/model-parser.ts +26 -0
|
@@ -18,6 +18,10 @@ export function generateCompactAzureFunctionsCRUD(model: ModelInfo, sharedPackag
|
|
|
18
18
|
const modelKebab = toKebabCase(modelName);
|
|
19
19
|
const schemaName = model.schemaName;
|
|
20
20
|
|
|
21
|
+
const partitionKeyPath = model.partitionKey; // e.g. "/tenantId"
|
|
22
|
+
const partitionKeyField = partitionKeyPath.slice(1); // e.g. "tenantId"
|
|
23
|
+
const isIdPartition = partitionKeyField === 'id';
|
|
24
|
+
|
|
21
25
|
const hasAuth = !!authPolicy;
|
|
22
26
|
const authImport = hasAuth ? `\n${generateAuthImportTS()}\n` : '';
|
|
23
27
|
const readGuard = hasAuth ? `\n${generateAuthGuardTS(authPolicy!, 'read')}\n` : '';
|
|
@@ -27,6 +31,34 @@ export function generateCompactAzureFunctionsCRUD(model: ModelInfo, sharedPackag
|
|
|
27
31
|
if (authErr) return authErr;\n`
|
|
28
32
|
: '';
|
|
29
33
|
|
|
34
|
+
// SDK クライアント初期化ヘルパー(PK≠/id の場合、および delete で使用)
|
|
35
|
+
const sdkClientInit = `const { CosmosClient } = await import('@azure/cosmos');
|
|
36
|
+
const endpoint = process.env.CosmosDBConnection__accountEndpoint;
|
|
37
|
+
let client: InstanceType<typeof CosmosClient>;
|
|
38
|
+
if (endpoint) {
|
|
39
|
+
const { DefaultAzureCredential } = await import('@azure/identity');
|
|
40
|
+
client = new CosmosClient({ endpoint, aadCredentials: new DefaultAzureCredential() });
|
|
41
|
+
} else {
|
|
42
|
+
client = new CosmosClient(process.env.CosmosDBConnection!);
|
|
43
|
+
}
|
|
44
|
+
const database = client.database(process.env.COSMOS_DB_DATABASE_NAME || 'AppDatabase');
|
|
45
|
+
const container = database.container(containerName);`;
|
|
46
|
+
|
|
47
|
+
// getById ハンドラー: PK=/id の場合は input binding、それ以外は SDK
|
|
48
|
+
const getByIdHandler = isIdPartition
|
|
49
|
+
? generateGetByIdWithBinding(modelCamel, schemaName, readGuard, authCatchBlock)
|
|
50
|
+
: generateGetByIdWithSdk(modelCamel, schemaName, readGuard, authCatchBlock, sdkClientInit);
|
|
51
|
+
|
|
52
|
+
// update ハンドラー: PK=/id の場合は input binding、それ以外は SDK
|
|
53
|
+
const updateHandler = isIdPartition
|
|
54
|
+
? generateUpdateWithBinding(modelCamel, schemaName, writeGuard, authCatchBlock)
|
|
55
|
+
: generateUpdateWithSdk(modelCamel, schemaName, partitionKeyField, writeGuard, authCatchBlock, sdkClientInit);
|
|
56
|
+
|
|
57
|
+
// delete ハンドラー: 常に SDK(既存パターン)、PK に応じて分岐
|
|
58
|
+
const deleteHandler = isIdPartition
|
|
59
|
+
? generateDeleteIdPartition(modelCamel, writeGuard, authCatchBlock, sdkClientInit)
|
|
60
|
+
: generateDeleteCustomPartition(modelCamel, partitionKeyField, writeGuard, authCatchBlock, sdkClientInit);
|
|
61
|
+
|
|
30
62
|
return `import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions';
|
|
31
63
|
import { z } from 'zod/v4';
|
|
32
64
|
import crypto from 'crypto';
|
|
@@ -68,38 +100,7 @@ ${authCatchBlock} context.error(\`Error fetching from \${containerName}:\`,
|
|
|
68
100
|
},
|
|
69
101
|
});
|
|
70
102
|
|
|
71
|
-
|
|
72
|
-
app.http('${modelCamel}-get-by-id', {
|
|
73
|
-
methods: ['GET'],
|
|
74
|
-
route: '${modelCamel}/{id}',
|
|
75
|
-
authLevel: 'anonymous',
|
|
76
|
-
extraInputs: [
|
|
77
|
-
{
|
|
78
|
-
type: 'cosmosDB',
|
|
79
|
-
name: 'cosmosInput',
|
|
80
|
-
databaseName: process.env.COSMOS_DB_DATABASE_NAME || 'AppDatabase',
|
|
81
|
-
containerName,
|
|
82
|
-
connection: 'CosmosDBConnection',
|
|
83
|
-
id: '{id}',
|
|
84
|
-
partitionKey: '{id}',
|
|
85
|
-
},
|
|
86
|
-
],
|
|
87
|
-
handler: async (request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> => {
|
|
88
|
-
try {${readGuard}
|
|
89
|
-
const document = context.extraInputs.get('cosmosInput');
|
|
90
|
-
|
|
91
|
-
if (!document) {
|
|
92
|
-
return { status: 404, jsonBody: { error: 'Item not found' } };
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const validated = ${schemaName}.parse(document);
|
|
96
|
-
return { status: 200, jsonBody: validated };
|
|
97
|
-
} catch (error) {
|
|
98
|
-
${authCatchBlock} context.error(\`Error fetching item from \${containerName}:\`, error);
|
|
99
|
-
return { status: 500, jsonBody: { error: 'Failed to fetch item' } };
|
|
100
|
-
}
|
|
101
|
-
},
|
|
102
|
-
});
|
|
103
|
+
${getByIdHandler}
|
|
103
104
|
|
|
104
105
|
// POST /api/${modelCamel} - 新規作成
|
|
105
106
|
app.http('${modelCamel}-create', {
|
|
@@ -145,7 +146,87 @@ ${authCatchBlock} context.error(\`Error creating item in \${containerName}:
|
|
|
145
146
|
},
|
|
146
147
|
});
|
|
147
148
|
|
|
148
|
-
|
|
149
|
+
${updateHandler}
|
|
150
|
+
|
|
151
|
+
${deleteHandler}
|
|
152
|
+
`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// --- TS getById ハンドラー生成ヘルパー ---
|
|
156
|
+
|
|
157
|
+
function generateGetByIdWithBinding(modelCamel: string, schemaName: string, readGuard: string, authCatchBlock: string): string {
|
|
158
|
+
return `// GET /api/${modelCamel}/{id} - ID指定取得
|
|
159
|
+
app.http('${modelCamel}-get-by-id', {
|
|
160
|
+
methods: ['GET'],
|
|
161
|
+
route: '${modelCamel}/{id}',
|
|
162
|
+
authLevel: 'anonymous',
|
|
163
|
+
extraInputs: [
|
|
164
|
+
{
|
|
165
|
+
type: 'cosmosDB',
|
|
166
|
+
name: 'cosmosInput',
|
|
167
|
+
databaseName: process.env.COSMOS_DB_DATABASE_NAME || 'AppDatabase',
|
|
168
|
+
containerName,
|
|
169
|
+
connection: 'CosmosDBConnection',
|
|
170
|
+
id: '{id}',
|
|
171
|
+
partitionKey: '{id}',
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
handler: async (request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> => {
|
|
175
|
+
try {${readGuard}
|
|
176
|
+
const document = context.extraInputs.get('cosmosInput');
|
|
177
|
+
|
|
178
|
+
if (!document) {
|
|
179
|
+
return { status: 404, jsonBody: { error: 'Item not found' } };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const validated = ${schemaName}.parse(document);
|
|
183
|
+
return { status: 200, jsonBody: validated };
|
|
184
|
+
} catch (error) {
|
|
185
|
+
${authCatchBlock} context.error(\`Error fetching item from \${containerName}:\`, error);
|
|
186
|
+
return { status: 500, jsonBody: { error: 'Failed to fetch item' } };
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
});`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function generateGetByIdWithSdk(modelCamel: string, schemaName: string, readGuard: string, authCatchBlock: string, sdkClientInit: string): string {
|
|
193
|
+
return `// GET /api/${modelCamel}/{id} - ID指定取得 (custom partition key)
|
|
194
|
+
app.http('${modelCamel}-get-by-id', {
|
|
195
|
+
methods: ['GET'],
|
|
196
|
+
route: '${modelCamel}/{id}',
|
|
197
|
+
authLevel: 'anonymous',
|
|
198
|
+
handler: async (request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> => {
|
|
199
|
+
try {${readGuard}
|
|
200
|
+
const id = request.params.id;
|
|
201
|
+
if (!id) {
|
|
202
|
+
return { status: 400, jsonBody: { error: 'ID is required' } };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
${sdkClientInit}
|
|
206
|
+
|
|
207
|
+
const { resources } = await container.items.query({
|
|
208
|
+
query: 'SELECT * FROM c WHERE c.id = @id',
|
|
209
|
+
parameters: [{ name: '@id', value: id }],
|
|
210
|
+
}).fetchAll();
|
|
211
|
+
|
|
212
|
+
if (!resources || resources.length === 0) {
|
|
213
|
+
return { status: 404, jsonBody: { error: 'Item not found' } };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const validated = ${schemaName}.parse(resources[0]);
|
|
217
|
+
return { status: 200, jsonBody: validated };
|
|
218
|
+
} catch (error) {
|
|
219
|
+
${authCatchBlock} context.error(\`Error fetching item from \${containerName}:\`, error);
|
|
220
|
+
return { status: 500, jsonBody: { error: 'Failed to fetch item' } };
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
});`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// --- TS update ハンドラー生成ヘルパー ---
|
|
227
|
+
|
|
228
|
+
function generateUpdateWithBinding(modelCamel: string, schemaName: string, writeGuard: string, authCatchBlock: string): string {
|
|
229
|
+
return `// PUT /api/${modelCamel}/{id} - 更新
|
|
149
230
|
app.http('${modelCamel}-update', {
|
|
150
231
|
methods: ['PUT'],
|
|
151
232
|
route: '${modelCamel}/{id}',
|
|
@@ -206,9 +287,66 @@ ${authCatchBlock} context.error(\`Error updating item in \${containerName}:
|
|
|
206
287
|
return { status: 500, jsonBody: { error: 'Failed to update item' } };
|
|
207
288
|
}
|
|
208
289
|
},
|
|
209
|
-
})
|
|
290
|
+
});`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function generateUpdateWithSdk(modelCamel: string, schemaName: string, partitionKeyField: string, writeGuard: string, authCatchBlock: string, sdkClientInit: string): string {
|
|
294
|
+
return `// PUT /api/${modelCamel}/{id} - 更新 (custom partition key)
|
|
295
|
+
app.http('${modelCamel}-update', {
|
|
296
|
+
methods: ['PUT'],
|
|
297
|
+
route: '${modelCamel}/{id}',
|
|
298
|
+
authLevel: 'anonymous',
|
|
299
|
+
handler: async (request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> => {
|
|
300
|
+
try {${writeGuard}
|
|
301
|
+
const id = request.params.id;
|
|
302
|
+
if (!id) {
|
|
303
|
+
return { status: 400, jsonBody: { error: 'ID is required' } };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
${sdkClientInit}
|
|
210
307
|
|
|
211
|
-
|
|
308
|
+
const { resources } = await container.items.query({
|
|
309
|
+
query: 'SELECT * FROM c WHERE c.id = @id',
|
|
310
|
+
parameters: [{ name: '@id', value: id }],
|
|
311
|
+
}).fetchAll();
|
|
312
|
+
|
|
313
|
+
if (!resources || resources.length === 0) {
|
|
314
|
+
return { status: 404, jsonBody: { error: 'Item not found' } };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const existingDocument = resources[0];
|
|
318
|
+
const body = await request.json() as any;
|
|
319
|
+
const { createdAt, updatedAt, ...userData } = body;
|
|
320
|
+
|
|
321
|
+
const dataWithManagedFields = {
|
|
322
|
+
...userData,
|
|
323
|
+
id,
|
|
324
|
+
createdAt: existingDocument.createdAt,
|
|
325
|
+
updatedAt: new Date().toISOString(),
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const result = ${schemaName}.safeParse(dataWithManagedFields);
|
|
329
|
+
|
|
330
|
+
if (!result.success) {
|
|
331
|
+
context.error('Validation failed:', result.error.issues);
|
|
332
|
+
return { status: 400, jsonBody: { error: 'Validation failed', details: result.error.issues } };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const pkValue = result.data.${partitionKeyField};
|
|
336
|
+
await container.items.upsert(result.data);
|
|
337
|
+
return { status: 200, jsonBody: result.data };
|
|
338
|
+
} catch (error) {
|
|
339
|
+
${authCatchBlock} context.error(\`Error updating item in \${containerName}:\`, error);
|
|
340
|
+
return { status: 500, jsonBody: { error: 'Failed to update item' } };
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
});`;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// --- TS delete ハンドラー生成ヘルパー ---
|
|
347
|
+
|
|
348
|
+
function generateDeleteIdPartition(modelCamel: string, writeGuard: string, authCatchBlock: string, sdkClientInit: string): string {
|
|
349
|
+
return `// DELETE /api/${modelCamel}/{id} - 削除
|
|
212
350
|
app.http('${modelCamel}-delete', {
|
|
213
351
|
methods: ['DELETE'],
|
|
214
352
|
route: '${modelCamel}/{id}',
|
|
@@ -220,17 +358,7 @@ app.http('${modelCamel}-delete', {
|
|
|
220
358
|
return { status: 400, jsonBody: { error: 'ID is required' } };
|
|
221
359
|
}
|
|
222
360
|
|
|
223
|
-
|
|
224
|
-
const endpoint = process.env.CosmosDBConnection__accountEndpoint;
|
|
225
|
-
let client: InstanceType<typeof CosmosClient>;
|
|
226
|
-
if (endpoint) {
|
|
227
|
-
const { DefaultAzureCredential } = await import('@azure/identity');
|
|
228
|
-
client = new CosmosClient({ endpoint, aadCredentials: new DefaultAzureCredential() });
|
|
229
|
-
} else {
|
|
230
|
-
client = new CosmosClient(process.env.CosmosDBConnection!);
|
|
231
|
-
}
|
|
232
|
-
const database = client.database(process.env.COSMOS_DB_DATABASE_NAME || 'AppDatabase');
|
|
233
|
-
const container = database.container(containerName);
|
|
361
|
+
${sdkClientInit}
|
|
234
362
|
|
|
235
363
|
await container.item(id, id).delete();
|
|
236
364
|
context.log(\`Deleted item \${id} from \${containerName}\`);
|
|
@@ -244,8 +372,48 @@ ${authCatchBlock} context.error(\`Error deleting item from \${containerName
|
|
|
244
372
|
return { status: 500, jsonBody: { error: 'Failed to delete item' } };
|
|
245
373
|
}
|
|
246
374
|
},
|
|
247
|
-
})
|
|
248
|
-
|
|
375
|
+
});`;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function generateDeleteCustomPartition(modelCamel: string, partitionKeyField: string, writeGuard: string, authCatchBlock: string, sdkClientInit: string): string {
|
|
379
|
+
return `// DELETE /api/${modelCamel}/{id} - 削除 (custom partition key)
|
|
380
|
+
app.http('${modelCamel}-delete', {
|
|
381
|
+
methods: ['DELETE'],
|
|
382
|
+
route: '${modelCamel}/{id}',
|
|
383
|
+
authLevel: 'anonymous',
|
|
384
|
+
handler: async (request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> => {
|
|
385
|
+
try {${writeGuard}
|
|
386
|
+
const id = request.params.id;
|
|
387
|
+
if (!id) {
|
|
388
|
+
return { status: 400, jsonBody: { error: 'ID is required' } };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
${sdkClientInit}
|
|
392
|
+
|
|
393
|
+
// パーティションキー値を取得するためにドキュメントを読み取り
|
|
394
|
+
const { resources } = await container.items.query({
|
|
395
|
+
query: 'SELECT * FROM c WHERE c.id = @id',
|
|
396
|
+
parameters: [{ name: '@id', value: id }],
|
|
397
|
+
}).fetchAll();
|
|
398
|
+
|
|
399
|
+
if (!resources || resources.length === 0) {
|
|
400
|
+
return { status: 404, jsonBody: { error: 'Item not found' } };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const pkValue = resources[0].${partitionKeyField};
|
|
404
|
+
await container.item(id, pkValue).delete();
|
|
405
|
+
context.log(\`Deleted item \${id} from \${containerName}\`);
|
|
406
|
+
|
|
407
|
+
return { status: 204 };
|
|
408
|
+
} catch (error: any) {
|
|
409
|
+
if (error.code === 404) {
|
|
410
|
+
return { status: 404, jsonBody: { error: 'Item not found' } };
|
|
411
|
+
}
|
|
412
|
+
${authCatchBlock} context.error(\`Error deleting item from \${containerName}:\`, error);
|
|
413
|
+
return { status: 500, jsonBody: { error: 'Failed to delete item' } };
|
|
414
|
+
}
|
|
415
|
+
},
|
|
416
|
+
});`;
|
|
249
417
|
}
|
|
250
418
|
|
|
251
419
|
export function generateCSharpAzureFunctionsCRUD(model: ModelInfo, authPolicy?: ModelAuthPolicy): string {
|
|
@@ -254,11 +422,78 @@ export function generateCSharpAzureFunctionsCRUD(model: ModelInfo, authPolicy?:
|
|
|
254
422
|
const className = `${modelName}Functions`;
|
|
255
423
|
const containerName = modelName.endsWith('s') ? modelName : `${modelName}s`;
|
|
256
424
|
|
|
425
|
+
const partitionKeyPath = model.partitionKey;
|
|
426
|
+
const partitionKeyField = partitionKeyPath.slice(1);
|
|
427
|
+
const isIdPartition = partitionKeyField === 'id';
|
|
428
|
+
|
|
257
429
|
const hasAuth = !!authPolicy;
|
|
258
430
|
const authUsing = hasAuth ? 'using Functions.Auth;\n' : '';
|
|
259
431
|
const readGuard = hasAuth ? `\n${generateAuthGuardCSharp(authPolicy!, 'read')}\n` : '';
|
|
260
432
|
const writeGuard = hasAuth ? `\n${generateAuthGuardCSharp(authPolicy!, 'write')}\n` : '';
|
|
261
433
|
|
|
434
|
+
// PK値の取得ロジック(create 用)
|
|
435
|
+
const createPkExpr = isIdPartition
|
|
436
|
+
? 'id'
|
|
437
|
+
: `payload["${partitionKeyField}"]?.GetValue<string>() ?? throw new InvalidOperationException("Partition key field '${partitionKeyField}' is required.")`;
|
|
438
|
+
|
|
439
|
+
// ReadCosmosItemAsync: PK=/id の場合は直接読み取り、それ以外はクエリ
|
|
440
|
+
const readItemMethod = isIdPartition
|
|
441
|
+
? ` private static async Task<JsonObject> ReadCosmosItemAsync(Container container, string id)
|
|
442
|
+
{
|
|
443
|
+
var response = await container.ReadItemStreamAsync(id, new PartitionKey(id));
|
|
444
|
+
if (response.StatusCode == HttpStatusCode.NotFound)
|
|
445
|
+
{
|
|
446
|
+
throw new CosmosException("Item not found", HttpStatusCode.NotFound, 0, response.Headers.ActivityId, response.Headers.RequestCharge);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (!response.IsSuccessStatusCode)
|
|
450
|
+
{
|
|
451
|
+
throw new InvalidOperationException($"Cosmos read failed with status {(int)response.StatusCode}.");
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
using var document = await JsonDocument.ParseAsync(response.Content);
|
|
455
|
+
return JsonNode.Parse(document.RootElement.GetRawText())?.AsObject()
|
|
456
|
+
?? throw new JsonException("Cosmos item payload must be a JSON object.");
|
|
457
|
+
}`
|
|
458
|
+
: ` private static async Task<JsonObject> ReadCosmosItemAsync(Container container, string id)
|
|
459
|
+
{
|
|
460
|
+
var query = new QueryDefinition("SELECT * FROM c WHERE c.id = @id")
|
|
461
|
+
.WithParameter("@id", id);
|
|
462
|
+
using var iterator = container.GetItemQueryStreamIterator(query);
|
|
463
|
+
|
|
464
|
+
while (iterator.HasMoreResults)
|
|
465
|
+
{
|
|
466
|
+
var page = await iterator.ReadNextAsync();
|
|
467
|
+
if (!page.IsSuccessStatusCode)
|
|
468
|
+
{
|
|
469
|
+
throw new InvalidOperationException($"Cosmos query failed with status {(int)page.StatusCode}.");
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
using var document = await JsonDocument.ParseAsync(page.Content);
|
|
473
|
+
if (document.RootElement.TryGetProperty("Documents", out var documents))
|
|
474
|
+
{
|
|
475
|
+
foreach (var item in documents.EnumerateArray())
|
|
476
|
+
{
|
|
477
|
+
return JsonNode.Parse(item.GetRawText())?.AsObject()
|
|
478
|
+
?? throw new JsonException("Cosmos item payload must be a JSON object.");
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
throw new CosmosException("Item not found", HttpStatusCode.NotFound, 0, "", 0);
|
|
484
|
+
}`;
|
|
485
|
+
|
|
486
|
+
// Replace / Delete の PK 解決ロジック
|
|
487
|
+
const replacePkExpr = isIdPartition
|
|
488
|
+
? 'new PartitionKey(id)'
|
|
489
|
+
: `new PartitionKey(payload["${partitionKeyField}"]?.GetValue<string>())`;
|
|
490
|
+
|
|
491
|
+
const deleteLogic = isIdPartition
|
|
492
|
+
? ` await container.DeleteItemAsync<JsonObject>(id, new PartitionKey(id));`
|
|
493
|
+
: ` var existing = await ReadCosmosItemAsync(container, id);
|
|
494
|
+
var pkValue = existing["${partitionKeyField}"]?.GetValue<string>();
|
|
495
|
+
await container.DeleteItemAsync<JsonObject>(id, new PartitionKey(pkValue));`;
|
|
496
|
+
|
|
262
497
|
return `using System.Net;
|
|
263
498
|
using System.Text;
|
|
264
499
|
using System.Text.Json;
|
|
@@ -351,23 +586,7 @@ public sealed class ${className}
|
|
|
351
586
|
private static Stream CreateJsonStream(JsonObject payload) =>
|
|
352
587
|
new MemoryStream(Encoding.UTF8.GetBytes(payload.ToJsonString()));
|
|
353
588
|
|
|
354
|
-
|
|
355
|
-
{
|
|
356
|
-
var response = await container.ReadItemStreamAsync(id, new PartitionKey(id));
|
|
357
|
-
if (response.StatusCode == HttpStatusCode.NotFound)
|
|
358
|
-
{
|
|
359
|
-
throw new CosmosException("Item not found", HttpStatusCode.NotFound, 0, response.Headers.ActivityId, response.Headers.RequestCharge);
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
if (!response.IsSuccessStatusCode)
|
|
363
|
-
{
|
|
364
|
-
throw new InvalidOperationException($"Cosmos read failed with status {(int)response.StatusCode}.");
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
using var document = await JsonDocument.ParseAsync(response.Content);
|
|
368
|
-
return JsonNode.Parse(document.RootElement.GetRawText())?.AsObject()
|
|
369
|
-
?? throw new JsonException("Cosmos item payload must be a JSON object.");
|
|
370
|
-
}
|
|
589
|
+
${readItemMethod}
|
|
371
590
|
|
|
372
591
|
private static async Task<HttpResponseData> WriteJsonAsync(HttpRequestData request, HttpStatusCode status, object payload)
|
|
373
592
|
{
|
|
@@ -454,7 +673,8 @@ public sealed class ${className}
|
|
|
454
673
|
using var client = CreateCosmosClient();
|
|
455
674
|
var container = GetContainer(client);
|
|
456
675
|
using var stream = CreateJsonStream(payload);
|
|
457
|
-
var
|
|
676
|
+
var pkValue = ${createPkExpr};
|
|
677
|
+
var response = await container.CreateItemStreamAsync(stream, new PartitionKey(pkValue));
|
|
458
678
|
if (!response.IsSuccessStatusCode)
|
|
459
679
|
{
|
|
460
680
|
throw new InvalidOperationException($"Cosmos create failed with status {(int)response.StatusCode}.");
|
|
@@ -499,7 +719,7 @@ public sealed class ${className}
|
|
|
499
719
|
var payload = BuildManagedDocument(body, id, createdAt, DateTimeOffset.UtcNow.ToString("O"));
|
|
500
720
|
|
|
501
721
|
using var stream = CreateJsonStream(payload);
|
|
502
|
-
var response = await container.ReplaceItemStreamAsync(stream, id,
|
|
722
|
+
var response = await container.ReplaceItemStreamAsync(stream, id, ${replacePkExpr});
|
|
503
723
|
if (!response.IsSuccessStatusCode)
|
|
504
724
|
{
|
|
505
725
|
throw new InvalidOperationException($"Cosmos replace failed with status {(int)response.StatusCode}.");
|
|
@@ -528,7 +748,7 @@ public sealed class ${className}
|
|
|
528
748
|
{${writeGuard}
|
|
529
749
|
using var client = CreateCosmosClient();
|
|
530
750
|
var container = GetContainer(client);
|
|
531
|
-
|
|
751
|
+
${deleteLogic}
|
|
532
752
|
|
|
533
753
|
return request.CreateResponse(HttpStatusCode.NoContent);
|
|
534
754
|
}
|
|
@@ -555,6 +775,10 @@ export function generatePythonAzureFunctionsCRUD(model: ModelInfo, authPolicy?:
|
|
|
555
775
|
const modelSnake = toKebabCase(modelName).replace(/-/g, "_");
|
|
556
776
|
const containerName = modelName.endsWith('s') ? modelName : `${modelName}s`;
|
|
557
777
|
|
|
778
|
+
const partitionKeyPath = model.partitionKey;
|
|
779
|
+
const partitionKeyField = partitionKeyPath.slice(1);
|
|
780
|
+
const isIdPartition = partitionKeyField === 'id';
|
|
781
|
+
|
|
558
782
|
const hasAuth = !!authPolicy;
|
|
559
783
|
const authImport = hasAuth ? '\nfrom auth.jwt_helper import require_auth, require_roles, handle_auth_error\n' : '';
|
|
560
784
|
// generateAuthGuardPython outputs at 4-space indent; inside try: we need 8-space
|
|
@@ -564,6 +788,76 @@ export function generatePythonAzureFunctionsCRUD(model: ModelInfo, authPolicy?:
|
|
|
564
788
|
const writeGuard = hasAuth ? '\n' + writeGuardRaw.split('\n').map(l => ' ' + l).join('\n') : '';
|
|
565
789
|
const authCatch = hasAuth ? `\n auth_err = handle_auth_error(exc)\n if auth_err:\n return auth_err` : '';
|
|
566
790
|
|
|
791
|
+
// getById / update の読み取りロジック
|
|
792
|
+
const getByIdRead = isIdPartition
|
|
793
|
+
? `container.read_item(item=item_id, partition_key=item_id)`
|
|
794
|
+
: `list(container.query_items(
|
|
795
|
+
query="SELECT * FROM c WHERE c.id = @id",
|
|
796
|
+
parameters=[{"name": "@id", "value": item_id}],
|
|
797
|
+
enable_cross_partition_query=True,
|
|
798
|
+
))`;
|
|
799
|
+
|
|
800
|
+
const getByIdBody = isIdPartition
|
|
801
|
+
? ` container = _get_container()
|
|
802
|
+
item = container.read_item(item=item_id, partition_key=item_id)
|
|
803
|
+
return _json_response(item, 200)`
|
|
804
|
+
: ` container = _get_container()
|
|
805
|
+
items = list(container.query_items(
|
|
806
|
+
query="SELECT * FROM c WHERE c.id = @id",
|
|
807
|
+
parameters=[{"name": "@id", "value": item_id}],
|
|
808
|
+
enable_cross_partition_query=True,
|
|
809
|
+
))
|
|
810
|
+
if not items:
|
|
811
|
+
return _json_response({"error": "${modelName} not found", "id": item_id}, 404)
|
|
812
|
+
return _json_response(items[0], 200)`;
|
|
813
|
+
|
|
814
|
+
const updateBody = isIdPartition
|
|
815
|
+
? ` container = _get_container()
|
|
816
|
+
existing = container.read_item(item=item_id, partition_key=item_id)
|
|
817
|
+
body = req.get_json()
|
|
818
|
+
payload = _build_managed_document(
|
|
819
|
+
body,
|
|
820
|
+
item_id,
|
|
821
|
+
existing.get("createdAt") or datetime.now(timezone.utc).isoformat(),
|
|
822
|
+
datetime.now(timezone.utc).isoformat(),
|
|
823
|
+
)
|
|
824
|
+
container.replace_item(item=item_id, body=payload)
|
|
825
|
+
return _json_response(payload, 200)`
|
|
826
|
+
: ` container = _get_container()
|
|
827
|
+
items = list(container.query_items(
|
|
828
|
+
query="SELECT * FROM c WHERE c.id = @id",
|
|
829
|
+
parameters=[{"name": "@id", "value": item_id}],
|
|
830
|
+
enable_cross_partition_query=True,
|
|
831
|
+
))
|
|
832
|
+
if not items:
|
|
833
|
+
return _json_response({"error": "${modelName} not found", "id": item_id}, 404)
|
|
834
|
+
existing = items[0]
|
|
835
|
+
body = req.get_json()
|
|
836
|
+
payload = _build_managed_document(
|
|
837
|
+
body,
|
|
838
|
+
item_id,
|
|
839
|
+
existing.get("createdAt") or datetime.now(timezone.utc).isoformat(),
|
|
840
|
+
datetime.now(timezone.utc).isoformat(),
|
|
841
|
+
)
|
|
842
|
+
container.replace_item(item=existing, body=payload)
|
|
843
|
+
return _json_response(payload, 200)`;
|
|
844
|
+
|
|
845
|
+
const deleteBody = isIdPartition
|
|
846
|
+
? ` container = _get_container()
|
|
847
|
+
container.delete_item(item=item_id, partition_key=item_id)
|
|
848
|
+
return func.HttpResponse(status_code=204)`
|
|
849
|
+
: ` container = _get_container()
|
|
850
|
+
items = list(container.query_items(
|
|
851
|
+
query="SELECT * FROM c WHERE c.id = @id",
|
|
852
|
+
parameters=[{"name": "@id", "value": item_id}],
|
|
853
|
+
enable_cross_partition_query=True,
|
|
854
|
+
))
|
|
855
|
+
if not items:
|
|
856
|
+
return _json_response({"error": "${modelName} not found", "id": item_id}, 404)
|
|
857
|
+
pk_value = items[0].get("${partitionKeyField}")
|
|
858
|
+
container.delete_item(item=item_id, partition_key=pk_value)
|
|
859
|
+
return func.HttpResponse(status_code=204)`;
|
|
860
|
+
|
|
567
861
|
return {
|
|
568
862
|
registration: `from blueprints.${modelSnake} import bp as ${modelSnake}_bp\napp.register_blueprint(${modelSnake}_bp)`,
|
|
569
863
|
blueprint: `import json
|
|
@@ -634,9 +928,7 @@ def ${modelSnake}_get_all(req: func.HttpRequest) -> func.HttpResponse:
|
|
|
634
928
|
def ${modelSnake}_get_by_id(req: func.HttpRequest) -> func.HttpResponse:
|
|
635
929
|
item_id = req.route_params.get("id")
|
|
636
930
|
try:${readGuard}
|
|
637
|
-
|
|
638
|
-
item = container.read_item(item=item_id, partition_key=item_id)
|
|
639
|
-
return _json_response(item, 200)
|
|
931
|
+
${getByIdBody}
|
|
640
932
|
except exceptions.CosmosResourceNotFoundError:
|
|
641
933
|
return _json_response({"error": "${modelName} not found", "id": item_id}, 404)
|
|
642
934
|
except Exception as exc:${authCatch}
|
|
@@ -664,17 +956,7 @@ def ${modelSnake}_create(req: func.HttpRequest) -> func.HttpResponse:
|
|
|
664
956
|
def ${modelSnake}_update(req: func.HttpRequest) -> func.HttpResponse:
|
|
665
957
|
item_id = req.route_params.get("id")
|
|
666
958
|
try:${writeGuard}
|
|
667
|
-
|
|
668
|
-
existing = container.read_item(item=item_id, partition_key=item_id)
|
|
669
|
-
body = req.get_json()
|
|
670
|
-
payload = _build_managed_document(
|
|
671
|
-
body,
|
|
672
|
-
item_id,
|
|
673
|
-
existing.get("createdAt") or datetime.now(timezone.utc).isoformat(),
|
|
674
|
-
datetime.now(timezone.utc).isoformat(),
|
|
675
|
-
)
|
|
676
|
-
container.replace_item(item=item_id, body=payload)
|
|
677
|
-
return _json_response(payload, 200)
|
|
959
|
+
${updateBody}
|
|
678
960
|
except exceptions.CosmosResourceNotFoundError:
|
|
679
961
|
return _json_response({"error": "${modelName} not found", "id": item_id}, 404)
|
|
680
962
|
except ValueError:
|
|
@@ -687,9 +969,7 @@ def ${modelSnake}_update(req: func.HttpRequest) -> func.HttpResponse:
|
|
|
687
969
|
def ${modelSnake}_delete(req: func.HttpRequest) -> func.HttpResponse:
|
|
688
970
|
item_id = req.route_params.get("id")
|
|
689
971
|
try:${writeGuard}
|
|
690
|
-
|
|
691
|
-
container.delete_item(item=item_id, partition_key=item_id)
|
|
692
|
-
return func.HttpResponse(status_code=204)
|
|
972
|
+
${deleteBody}
|
|
693
973
|
except exceptions.CosmosResourceNotFoundError:
|
|
694
974
|
return _json_response({"error": "${modelName} not found", "id": item_id}, 404)
|
|
695
975
|
except Exception as exc:${authCatch}
|
|
@@ -20,6 +20,7 @@ export interface ModelInfo {
|
|
|
20
20
|
nestedSchemaRefs: NestedSchemaRef[]; // ネストしたスキーマ参照
|
|
21
21
|
connectorConfig?: ModelConnectorConfig; // コネクタメタデータ(外部データソース用)
|
|
22
22
|
authPolicy?: ModelAuthPolicy; // 認可ポリシー(ロールベースアクセス制御用)
|
|
23
|
+
partitionKey: string; // Cosmos DB パーティションキーパス(デフォルト: "/id")
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
export interface FieldInfo {
|
|
@@ -89,6 +90,9 @@ export async function parseModelFile(modelPath: string): Promise<ModelInfo> {
|
|
|
89
90
|
|
|
90
91
|
// authPolicy を抽出(ロールベースアクセス制御用メタデータ)
|
|
91
92
|
const authPolicy = parseAuthPolicy(content);
|
|
93
|
+
|
|
94
|
+
// partitionKey を抽出(Cosmos DB パーティションキー)
|
|
95
|
+
const partitionKey = parsePartitionKey(content);
|
|
92
96
|
|
|
93
97
|
// ネストしたスキーマ参照を検出
|
|
94
98
|
const nestedSchemaRefs = detectNestedSchemaRefs(modelPath, content, schemaName);
|
|
@@ -103,6 +107,17 @@ export async function parseModelFile(modelPath: string): Promise<ModelInfo> {
|
|
|
103
107
|
const hasId = fields.some(f => f.name === "id");
|
|
104
108
|
const hasCreatedAt = fields.some(f => f.name === "createdAt");
|
|
105
109
|
const hasUpdatedAt = fields.some(f => f.name === "updatedAt");
|
|
110
|
+
|
|
111
|
+
// パーティションキーのバリデーション
|
|
112
|
+
if (partitionKey !== '/id') {
|
|
113
|
+
if (!partitionKey.startsWith('/')) {
|
|
114
|
+
console.warn(`⚠️ [${modelName}] partitionKey should start with '/': got '${partitionKey}'`);
|
|
115
|
+
}
|
|
116
|
+
const pkField = partitionKey.startsWith('/') ? partitionKey.slice(1) : partitionKey;
|
|
117
|
+
if (fields.length > 0 && !fields.some(f => f.name === pkField)) {
|
|
118
|
+
console.warn(`⚠️ [${modelName}] partitionKey field '${pkField}' not found in schema fields`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
106
121
|
|
|
107
122
|
return {
|
|
108
123
|
name: modelName,
|
|
@@ -114,6 +129,7 @@ export async function parseModelFile(modelPath: string): Promise<ModelInfo> {
|
|
|
114
129
|
hasCreatedAt,
|
|
115
130
|
hasUpdatedAt,
|
|
116
131
|
nestedSchemaRefs,
|
|
132
|
+
partitionKey,
|
|
117
133
|
...(connectorConfig ? { connectorConfig } : {}),
|
|
118
134
|
...(authPolicy ? { authPolicy } : {}),
|
|
119
135
|
};
|
|
@@ -778,6 +794,16 @@ export function parseAuthPolicy(content: string): ModelAuthPolicy | undefined {
|
|
|
778
794
|
};
|
|
779
795
|
}
|
|
780
796
|
|
|
797
|
+
/**
|
|
798
|
+
* パーティションキーを抽出する
|
|
799
|
+
* export const partitionKey = '/tenantId' のようなエクスポートを検出
|
|
800
|
+
* 未指定の場合はデフォルト '/id' を返す
|
|
801
|
+
*/
|
|
802
|
+
export function parsePartitionKey(content: string): string {
|
|
803
|
+
const match = content.match(/export\s+const\s+partitionKey\s*=\s*['"]([^'"]+)['"]/);
|
|
804
|
+
return match ? match[1] : '/id';
|
|
805
|
+
}
|
|
806
|
+
|
|
781
807
|
/**
|
|
782
808
|
* 文字列を kebab-case に変換
|
|
783
809
|
*/
|