swallowkit 1.0.0-beta.15 → 1.0.0-beta.17
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/add-auth.d.ts.map +1 -1
- package/dist/cli/commands/add-auth.js +85 -5
- package/dist/cli/commands/add-auth.js.map +1 -1
- package/dist/cli/commands/create-model.js +1 -1
- package/dist/cli/commands/create-model.js.map +1 -1
- package/dist/cli/commands/dev-seeds.js +5 -5
- package/dist/cli/commands/dev-seeds.js.map +1 -1
- package/dist/cli/commands/dev.js +64 -24
- package/dist/cli/commands/dev.js.map +1 -1
- package/dist/cli/commands/init.js +4 -4
- package/dist/cli/commands/scaffold.d.ts.map +1 -1
- package/dist/cli/commands/scaffold.js +61 -5
- package/dist/cli/commands/scaffold.js.map +1 -1
- package/dist/core/scaffold/auth-generator.d.ts +1 -1
- package/dist/core/scaffold/auth-generator.d.ts.map +1 -1
- package/dist/core/scaffold/auth-generator.js +17 -20
- package/dist/core/scaffold/auth-generator.js.map +1 -1
- package/dist/core/scaffold/functions-generator.d.ts +2 -2
- package/dist/core/scaffold/functions-generator.d.ts.map +1 -1
- package/dist/core/scaffold/functions-generator.js +375 -107
- 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 +25 -0
- package/dist/core/scaffold/model-parser.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/__snapshots__/functions-generator.test.ts.snap +694 -0
- package/src/__tests__/auth.test.ts +13 -13
- 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/add-auth.ts +95 -6
- package/src/cli/commands/create-model.ts +1 -1
- package/src/cli/commands/dev-seeds.ts +5 -5
- package/src/cli/commands/dev.ts +67 -23
- package/src/cli/commands/init.ts +4 -4
- package/src/cli/commands/scaffold.ts +69 -10
- package/src/core/scaffold/auth-generator.ts +16 -19
- package/src/core/scaffold/functions-generator.ts +402 -108
- package/src/core/scaffold/model-parser.ts +28 -0
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { ModelInfo, toCamelCase, toKebabCase } from "./model-parser";
|
|
8
8
|
import { ModelAuthPolicy } from "../../types";
|
|
9
|
-
import { generateAuthImportTS, generateAuthGuardTS } from "./auth-generator";
|
|
9
|
+
import { generateAuthImportTS, generateAuthGuardTS, generateAuthGuardCSharp, generateAuthGuardPython } from "./auth-generator";
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Azure Functions エンティティファイルを生成(インラインハンドラー方式)
|
|
@@ -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,12 +31,40 @@ 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';
|
|
33
65
|
import { ${schemaName} } from '${sharedPackageName}';${authImport}
|
|
34
66
|
|
|
35
|
-
const containerName = '${modelName
|
|
67
|
+
const containerName = '${modelName.endsWith('s') ? modelName : modelName + 's'}';
|
|
36
68
|
|
|
37
69
|
// GET /api/${modelCamel} - 全件取得
|
|
38
70
|
app.http('${modelCamel}-get-all', {
|
|
@@ -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}
|
|
307
|
+
|
|
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
|
+
}
|
|
210
334
|
|
|
211
|
-
|
|
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,15 +372,127 @@ ${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
|
+
});`;
|
|
249
376
|
}
|
|
250
377
|
|
|
251
|
-
|
|
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
|
+
});`;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export function generateCSharpAzureFunctionsCRUD(model: ModelInfo, authPolicy?: ModelAuthPolicy): string {
|
|
252
420
|
const modelName = model.name;
|
|
253
421
|
const modelCamel = toCamelCase(modelName);
|
|
254
422
|
const className = `${modelName}Functions`;
|
|
255
|
-
const containerName = `${modelName}s`;
|
|
423
|
+
const containerName = modelName.endsWith('s') ? modelName : `${modelName}s`;
|
|
424
|
+
|
|
425
|
+
const partitionKeyPath = model.partitionKey;
|
|
426
|
+
const partitionKeyField = partitionKeyPath.slice(1);
|
|
427
|
+
const isIdPartition = partitionKeyField === 'id';
|
|
428
|
+
|
|
429
|
+
const hasAuth = !!authPolicy;
|
|
430
|
+
const authUsing = hasAuth ? 'using Functions.Auth;\n' : '';
|
|
431
|
+
const readGuard = hasAuth ? `\n${generateAuthGuardCSharp(authPolicy!, 'read')}\n` : '';
|
|
432
|
+
const writeGuard = hasAuth ? `\n${generateAuthGuardCSharp(authPolicy!, 'write')}\n` : '';
|
|
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));`;
|
|
256
496
|
|
|
257
497
|
return `using System.Net;
|
|
258
498
|
using System.Text;
|
|
@@ -263,7 +503,7 @@ using Microsoft.Azure.Cosmos;
|
|
|
263
503
|
using Microsoft.Azure.Functions.Worker;
|
|
264
504
|
using Microsoft.Azure.Functions.Worker.Http;
|
|
265
505
|
using Microsoft.Extensions.Logging;
|
|
266
|
-
|
|
506
|
+
${authUsing}
|
|
267
507
|
namespace SwallowKit.Functions;
|
|
268
508
|
|
|
269
509
|
public sealed class ${className}
|
|
@@ -346,23 +586,7 @@ public sealed class ${className}
|
|
|
346
586
|
private static Stream CreateJsonStream(JsonObject payload) =>
|
|
347
587
|
new MemoryStream(Encoding.UTF8.GetBytes(payload.ToJsonString()));
|
|
348
588
|
|
|
349
|
-
|
|
350
|
-
{
|
|
351
|
-
var response = await container.ReadItemStreamAsync(id, new PartitionKey(id));
|
|
352
|
-
if (response.StatusCode == HttpStatusCode.NotFound)
|
|
353
|
-
{
|
|
354
|
-
throw new CosmosException("Item not found", HttpStatusCode.NotFound, 0, response.Headers.ActivityId, response.Headers.RequestCharge);
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
if (!response.IsSuccessStatusCode)
|
|
358
|
-
{
|
|
359
|
-
throw new InvalidOperationException($"Cosmos read failed with status {(int)response.StatusCode}.");
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
using var document = await JsonDocument.ParseAsync(response.Content);
|
|
363
|
-
return JsonNode.Parse(document.RootElement.GetRawText())?.AsObject()
|
|
364
|
-
?? throw new JsonException("Cosmos item payload must be a JSON object.");
|
|
365
|
-
}
|
|
589
|
+
${readItemMethod}
|
|
366
590
|
|
|
367
591
|
private static async Task<HttpResponseData> WriteJsonAsync(HttpRequestData request, HttpStatusCode status, object payload)
|
|
368
592
|
{
|
|
@@ -376,7 +600,7 @@ public sealed class ${className}
|
|
|
376
600
|
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "${modelCamel}")] HttpRequestData request)
|
|
377
601
|
{
|
|
378
602
|
try
|
|
379
|
-
{
|
|
603
|
+
{${readGuard}
|
|
380
604
|
using var client = CreateCosmosClient();
|
|
381
605
|
var container = GetContainer(client);
|
|
382
606
|
using var iterator = container.GetItemQueryStreamIterator("SELECT * FROM c");
|
|
@@ -418,7 +642,7 @@ public sealed class ${className}
|
|
|
418
642
|
string id)
|
|
419
643
|
{
|
|
420
644
|
try
|
|
421
|
-
{
|
|
645
|
+
{${readGuard}
|
|
422
646
|
using var client = CreateCosmosClient();
|
|
423
647
|
var container = GetContainer(client);
|
|
424
648
|
var item = await ReadCosmosItemAsync(container, id);
|
|
@@ -440,7 +664,7 @@ public sealed class ${className}
|
|
|
440
664
|
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "${modelCamel}")] HttpRequestData request)
|
|
441
665
|
{
|
|
442
666
|
try
|
|
443
|
-
{
|
|
667
|
+
{${writeGuard}
|
|
444
668
|
var body = await ReadRequestBodyAsync(request);
|
|
445
669
|
var now = DateTimeOffset.UtcNow.ToString("O");
|
|
446
670
|
var id = body["id"]?.GetValue<string>() ?? Guid.NewGuid().ToString();
|
|
@@ -449,7 +673,8 @@ public sealed class ${className}
|
|
|
449
673
|
using var client = CreateCosmosClient();
|
|
450
674
|
var container = GetContainer(client);
|
|
451
675
|
using var stream = CreateJsonStream(payload);
|
|
452
|
-
var
|
|
676
|
+
var pkValue = ${createPkExpr};
|
|
677
|
+
var response = await container.CreateItemStreamAsync(stream, new PartitionKey(pkValue));
|
|
453
678
|
if (!response.IsSuccessStatusCode)
|
|
454
679
|
{
|
|
455
680
|
throw new InvalidOperationException($"Cosmos create failed with status {(int)response.StatusCode}.");
|
|
@@ -475,7 +700,7 @@ public sealed class ${className}
|
|
|
475
700
|
string id)
|
|
476
701
|
{
|
|
477
702
|
try
|
|
478
|
-
{
|
|
703
|
+
{${writeGuard}
|
|
479
704
|
using var client = CreateCosmosClient();
|
|
480
705
|
var container = GetContainer(client);
|
|
481
706
|
|
|
@@ -494,7 +719,7 @@ public sealed class ${className}
|
|
|
494
719
|
var payload = BuildManagedDocument(body, id, createdAt, DateTimeOffset.UtcNow.ToString("O"));
|
|
495
720
|
|
|
496
721
|
using var stream = CreateJsonStream(payload);
|
|
497
|
-
var response = await container.ReplaceItemStreamAsync(stream, id,
|
|
722
|
+
var response = await container.ReplaceItemStreamAsync(stream, id, ${replacePkExpr});
|
|
498
723
|
if (!response.IsSuccessStatusCode)
|
|
499
724
|
{
|
|
500
725
|
throw new InvalidOperationException($"Cosmos replace failed with status {(int)response.StatusCode}.");
|
|
@@ -520,10 +745,10 @@ public sealed class ${className}
|
|
|
520
745
|
string id)
|
|
521
746
|
{
|
|
522
747
|
try
|
|
523
|
-
{
|
|
748
|
+
{${writeGuard}
|
|
524
749
|
using var client = CreateCosmosClient();
|
|
525
750
|
var container = GetContainer(client);
|
|
526
|
-
|
|
751
|
+
${deleteLogic}
|
|
527
752
|
|
|
528
753
|
return request.CreateResponse(HttpStatusCode.NoContent);
|
|
529
754
|
}
|
|
@@ -541,14 +766,97 @@ public sealed class ${className}
|
|
|
541
766
|
`;
|
|
542
767
|
}
|
|
543
768
|
|
|
544
|
-
export function generatePythonAzureFunctionsCRUD(model: ModelInfo): {
|
|
769
|
+
export function generatePythonAzureFunctionsCRUD(model: ModelInfo, authPolicy?: ModelAuthPolicy): {
|
|
545
770
|
blueprint: string;
|
|
546
771
|
registration: string;
|
|
547
772
|
} {
|
|
548
773
|
const modelName = model.name;
|
|
549
774
|
const modelCamel = toCamelCase(modelName);
|
|
550
775
|
const modelSnake = toKebabCase(modelName).replace(/-/g, "_");
|
|
551
|
-
const containerName = `${modelName}s`;
|
|
776
|
+
const containerName = modelName.endsWith('s') ? modelName : `${modelName}s`;
|
|
777
|
+
|
|
778
|
+
const partitionKeyPath = model.partitionKey;
|
|
779
|
+
const partitionKeyField = partitionKeyPath.slice(1);
|
|
780
|
+
const isIdPartition = partitionKeyField === 'id';
|
|
781
|
+
|
|
782
|
+
const hasAuth = !!authPolicy;
|
|
783
|
+
const authImport = hasAuth ? '\nfrom auth.jwt_helper import require_auth, require_roles, handle_auth_error\n' : '';
|
|
784
|
+
// generateAuthGuardPython outputs at 4-space indent; inside try: we need 8-space
|
|
785
|
+
const readGuardRaw = hasAuth ? generateAuthGuardPython(authPolicy!, 'read') : '';
|
|
786
|
+
const writeGuardRaw = hasAuth ? generateAuthGuardPython(authPolicy!, 'write') : '';
|
|
787
|
+
const readGuard = hasAuth ? '\n' + readGuardRaw.split('\n').map(l => ' ' + l).join('\n') : '';
|
|
788
|
+
const writeGuard = hasAuth ? '\n' + writeGuardRaw.split('\n').map(l => ' ' + l).join('\n') : '';
|
|
789
|
+
const authCatch = hasAuth ? `\n auth_err = handle_auth_error(exc)\n if auth_err:\n return auth_err` : '';
|
|
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)`;
|
|
552
860
|
|
|
553
861
|
return {
|
|
554
862
|
registration: `from blueprints.${modelSnake} import bp as ${modelSnake}_bp\napp.register_blueprint(${modelSnake}_bp)`,
|
|
@@ -561,7 +869,7 @@ import os
|
|
|
561
869
|
import azure.functions as func
|
|
562
870
|
from azure.cosmos import CosmosClient, exceptions
|
|
563
871
|
from azure.identity import DefaultAzureCredential
|
|
564
|
-
|
|
872
|
+
${authImport}
|
|
565
873
|
bp = func.Blueprint()
|
|
566
874
|
CONTAINER_NAME = "${containerName}"
|
|
567
875
|
DATABASE_NAME = os.environ.get("COSMOS_DB_DATABASE_NAME", "AppDatabase")
|
|
@@ -603,7 +911,7 @@ def _build_managed_document(source: dict[str, Any], item_id: str, created_at: st
|
|
|
603
911
|
|
|
604
912
|
@bp.route(route="${modelCamel}", methods=["GET"])
|
|
605
913
|
def ${modelSnake}_get_all(req: func.HttpRequest) -> func.HttpResponse:
|
|
606
|
-
try
|
|
914
|
+
try:${readGuard}
|
|
607
915
|
container = _get_container()
|
|
608
916
|
items = list(
|
|
609
917
|
container.query_items(
|
|
@@ -612,26 +920,24 @@ def ${modelSnake}_get_all(req: func.HttpRequest) -> func.HttpResponse:
|
|
|
612
920
|
)
|
|
613
921
|
)
|
|
614
922
|
return _json_response(items, 200)
|
|
615
|
-
except Exception as exc
|
|
923
|
+
except Exception as exc:${authCatch}
|
|
616
924
|
return _json_response({"error": "Failed to fetch items", "details": str(exc)}, 500)
|
|
617
925
|
|
|
618
926
|
|
|
619
927
|
@bp.route(route="${modelCamel}/{id}", methods=["GET"])
|
|
620
928
|
def ${modelSnake}_get_by_id(req: func.HttpRequest) -> func.HttpResponse:
|
|
621
929
|
item_id = req.route_params.get("id")
|
|
622
|
-
try
|
|
623
|
-
|
|
624
|
-
item = container.read_item(item=item_id, partition_key=item_id)
|
|
625
|
-
return _json_response(item, 200)
|
|
930
|
+
try:${readGuard}
|
|
931
|
+
${getByIdBody}
|
|
626
932
|
except exceptions.CosmosResourceNotFoundError:
|
|
627
933
|
return _json_response({"error": "${modelName} not found", "id": item_id}, 404)
|
|
628
|
-
except Exception as exc
|
|
934
|
+
except Exception as exc:${authCatch}
|
|
629
935
|
return _json_response({"error": "Failed to fetch item", "id": item_id, "details": str(exc)}, 500)
|
|
630
936
|
|
|
631
937
|
|
|
632
938
|
@bp.route(route="${modelCamel}", methods=["POST"])
|
|
633
939
|
def ${modelSnake}_create(req: func.HttpRequest) -> func.HttpResponse:
|
|
634
|
-
try
|
|
940
|
+
try:${writeGuard}
|
|
635
941
|
body = req.get_json()
|
|
636
942
|
now = datetime.now(timezone.utc).isoformat()
|
|
637
943
|
item_id = body.get("id") or str(uuid4())
|
|
@@ -642,43 +948,31 @@ def ${modelSnake}_create(req: func.HttpRequest) -> func.HttpResponse:
|
|
|
642
948
|
return _json_response(payload, 201)
|
|
643
949
|
except ValueError:
|
|
644
950
|
return _json_response({"error": "Request body must be a JSON object."}, 400)
|
|
645
|
-
except Exception as exc
|
|
951
|
+
except Exception as exc:${authCatch}
|
|
646
952
|
return _json_response({"error": "Failed to create item", "details": str(exc)}, 500)
|
|
647
953
|
|
|
648
954
|
|
|
649
955
|
@bp.route(route="${modelCamel}/{id}", methods=["PUT"])
|
|
650
956
|
def ${modelSnake}_update(req: func.HttpRequest) -> func.HttpResponse:
|
|
651
957
|
item_id = req.route_params.get("id")
|
|
652
|
-
try
|
|
653
|
-
|
|
654
|
-
existing = container.read_item(item=item_id, partition_key=item_id)
|
|
655
|
-
body = req.get_json()
|
|
656
|
-
payload = _build_managed_document(
|
|
657
|
-
body,
|
|
658
|
-
item_id,
|
|
659
|
-
existing.get("createdAt") or datetime.now(timezone.utc).isoformat(),
|
|
660
|
-
datetime.now(timezone.utc).isoformat(),
|
|
661
|
-
)
|
|
662
|
-
container.replace_item(item=item_id, body=payload)
|
|
663
|
-
return _json_response(payload, 200)
|
|
958
|
+
try:${writeGuard}
|
|
959
|
+
${updateBody}
|
|
664
960
|
except exceptions.CosmosResourceNotFoundError:
|
|
665
961
|
return _json_response({"error": "${modelName} not found", "id": item_id}, 404)
|
|
666
962
|
except ValueError:
|
|
667
963
|
return _json_response({"error": "Request body must be a JSON object.", "id": item_id}, 400)
|
|
668
|
-
except Exception as exc
|
|
964
|
+
except Exception as exc:${authCatch}
|
|
669
965
|
return _json_response({"error": "Failed to update item", "id": item_id, "details": str(exc)}, 500)
|
|
670
966
|
|
|
671
967
|
|
|
672
968
|
@bp.route(route="${modelCamel}/{id}", methods=["DELETE"])
|
|
673
969
|
def ${modelSnake}_delete(req: func.HttpRequest) -> func.HttpResponse:
|
|
674
970
|
item_id = req.route_params.get("id")
|
|
675
|
-
try
|
|
676
|
-
|
|
677
|
-
container.delete_item(item=item_id, partition_key=item_id)
|
|
678
|
-
return func.HttpResponse(status_code=204)
|
|
971
|
+
try:${writeGuard}
|
|
972
|
+
${deleteBody}
|
|
679
973
|
except exceptions.CosmosResourceNotFoundError:
|
|
680
974
|
return _json_response({"error": "${modelName} not found", "id": item_id}, 404)
|
|
681
|
-
except Exception as exc
|
|
975
|
+
except Exception as exc:${authCatch}
|
|
682
976
|
return _json_response({"error": "Failed to delete item", "id": item_id, "details": str(exc)}, 500)
|
|
683
977
|
`,
|
|
684
978
|
};
|