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
|
@@ -19,6 +19,9 @@ function generateCompactAzureFunctionsCRUD(model, sharedPackageName, authPolicy)
|
|
|
19
19
|
const modelCamel = (0, model_parser_1.toCamelCase)(modelName);
|
|
20
20
|
const modelKebab = (0, model_parser_1.toKebabCase)(modelName);
|
|
21
21
|
const schemaName = model.schemaName;
|
|
22
|
+
const partitionKeyPath = model.partitionKey; // e.g. "/tenantId"
|
|
23
|
+
const partitionKeyField = partitionKeyPath.slice(1); // e.g. "tenantId"
|
|
24
|
+
const isIdPartition = partitionKeyField === 'id';
|
|
22
25
|
const hasAuth = !!authPolicy;
|
|
23
26
|
const authImport = hasAuth ? `\n${(0, auth_generator_1.generateAuthImportTS)()}\n` : '';
|
|
24
27
|
const readGuard = hasAuth ? `\n${(0, auth_generator_1.generateAuthGuardTS)(authPolicy, 'read')}\n` : '';
|
|
@@ -27,12 +30,36 @@ function generateCompactAzureFunctionsCRUD(model, sharedPackageName, authPolicy)
|
|
|
27
30
|
? ` const authErr = handleAuthError(error);
|
|
28
31
|
if (authErr) return authErr;\n`
|
|
29
32
|
: '';
|
|
33
|
+
// SDK クライアント初期化ヘルパー(PK≠/id の場合、および delete で使用)
|
|
34
|
+
const sdkClientInit = `const { CosmosClient } = await import('@azure/cosmos');
|
|
35
|
+
const endpoint = process.env.CosmosDBConnection__accountEndpoint;
|
|
36
|
+
let client: InstanceType<typeof CosmosClient>;
|
|
37
|
+
if (endpoint) {
|
|
38
|
+
const { DefaultAzureCredential } = await import('@azure/identity');
|
|
39
|
+
client = new CosmosClient({ endpoint, aadCredentials: new DefaultAzureCredential() });
|
|
40
|
+
} else {
|
|
41
|
+
client = new CosmosClient(process.env.CosmosDBConnection!);
|
|
42
|
+
}
|
|
43
|
+
const database = client.database(process.env.COSMOS_DB_DATABASE_NAME || 'AppDatabase');
|
|
44
|
+
const container = database.container(containerName);`;
|
|
45
|
+
// getById ハンドラー: PK=/id の場合は input binding、それ以外は SDK
|
|
46
|
+
const getByIdHandler = isIdPartition
|
|
47
|
+
? generateGetByIdWithBinding(modelCamel, schemaName, readGuard, authCatchBlock)
|
|
48
|
+
: generateGetByIdWithSdk(modelCamel, schemaName, readGuard, authCatchBlock, sdkClientInit);
|
|
49
|
+
// update ハンドラー: PK=/id の場合は input binding、それ以外は SDK
|
|
50
|
+
const updateHandler = isIdPartition
|
|
51
|
+
? generateUpdateWithBinding(modelCamel, schemaName, writeGuard, authCatchBlock)
|
|
52
|
+
: generateUpdateWithSdk(modelCamel, schemaName, partitionKeyField, writeGuard, authCatchBlock, sdkClientInit);
|
|
53
|
+
// delete ハンドラー: 常に SDK(既存パターン)、PK に応じて分岐
|
|
54
|
+
const deleteHandler = isIdPartition
|
|
55
|
+
? generateDeleteIdPartition(modelCamel, writeGuard, authCatchBlock, sdkClientInit)
|
|
56
|
+
: generateDeleteCustomPartition(modelCamel, partitionKeyField, writeGuard, authCatchBlock, sdkClientInit);
|
|
30
57
|
return `import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions';
|
|
31
58
|
import { z } from 'zod/v4';
|
|
32
59
|
import crypto from 'crypto';
|
|
33
60
|
import { ${schemaName} } from '${sharedPackageName}';${authImport}
|
|
34
61
|
|
|
35
|
-
const containerName = '${modelName
|
|
62
|
+
const containerName = '${modelName.endsWith('s') ? modelName : modelName + 's'}';
|
|
36
63
|
|
|
37
64
|
// GET /api/${modelCamel} - 全件取得
|
|
38
65
|
app.http('${modelCamel}-get-all', {
|
|
@@ -68,38 +95,7 @@ ${authCatchBlock} context.error(\`Error fetching from \${containerName}:\`,
|
|
|
68
95
|
},
|
|
69
96
|
});
|
|
70
97
|
|
|
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
|
-
});
|
|
98
|
+
${getByIdHandler}
|
|
103
99
|
|
|
104
100
|
// POST /api/${modelCamel} - 新規作成
|
|
105
101
|
app.http('${modelCamel}-create', {
|
|
@@ -145,7 +141,82 @@ ${authCatchBlock} context.error(\`Error creating item in \${containerName}:
|
|
|
145
141
|
},
|
|
146
142
|
});
|
|
147
143
|
|
|
148
|
-
|
|
144
|
+
${updateHandler}
|
|
145
|
+
|
|
146
|
+
${deleteHandler}
|
|
147
|
+
`;
|
|
148
|
+
}
|
|
149
|
+
// --- TS getById ハンドラー生成ヘルパー ---
|
|
150
|
+
function generateGetByIdWithBinding(modelCamel, schemaName, readGuard, authCatchBlock) {
|
|
151
|
+
return `// GET /api/${modelCamel}/{id} - ID指定取得
|
|
152
|
+
app.http('${modelCamel}-get-by-id', {
|
|
153
|
+
methods: ['GET'],
|
|
154
|
+
route: '${modelCamel}/{id}',
|
|
155
|
+
authLevel: 'anonymous',
|
|
156
|
+
extraInputs: [
|
|
157
|
+
{
|
|
158
|
+
type: 'cosmosDB',
|
|
159
|
+
name: 'cosmosInput',
|
|
160
|
+
databaseName: process.env.COSMOS_DB_DATABASE_NAME || 'AppDatabase',
|
|
161
|
+
containerName,
|
|
162
|
+
connection: 'CosmosDBConnection',
|
|
163
|
+
id: '{id}',
|
|
164
|
+
partitionKey: '{id}',
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
handler: async (request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> => {
|
|
168
|
+
try {${readGuard}
|
|
169
|
+
const document = context.extraInputs.get('cosmosInput');
|
|
170
|
+
|
|
171
|
+
if (!document) {
|
|
172
|
+
return { status: 404, jsonBody: { error: 'Item not found' } };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const validated = ${schemaName}.parse(document);
|
|
176
|
+
return { status: 200, jsonBody: validated };
|
|
177
|
+
} catch (error) {
|
|
178
|
+
${authCatchBlock} context.error(\`Error fetching item from \${containerName}:\`, error);
|
|
179
|
+
return { status: 500, jsonBody: { error: 'Failed to fetch item' } };
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
});`;
|
|
183
|
+
}
|
|
184
|
+
function generateGetByIdWithSdk(modelCamel, schemaName, readGuard, authCatchBlock, sdkClientInit) {
|
|
185
|
+
return `// GET /api/${modelCamel}/{id} - ID指定取得 (custom partition key)
|
|
186
|
+
app.http('${modelCamel}-get-by-id', {
|
|
187
|
+
methods: ['GET'],
|
|
188
|
+
route: '${modelCamel}/{id}',
|
|
189
|
+
authLevel: 'anonymous',
|
|
190
|
+
handler: async (request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> => {
|
|
191
|
+
try {${readGuard}
|
|
192
|
+
const id = request.params.id;
|
|
193
|
+
if (!id) {
|
|
194
|
+
return { status: 400, jsonBody: { error: 'ID is required' } };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
${sdkClientInit}
|
|
198
|
+
|
|
199
|
+
const { resources } = await container.items.query({
|
|
200
|
+
query: 'SELECT * FROM c WHERE c.id = @id',
|
|
201
|
+
parameters: [{ name: '@id', value: id }],
|
|
202
|
+
}).fetchAll();
|
|
203
|
+
|
|
204
|
+
if (!resources || resources.length === 0) {
|
|
205
|
+
return { status: 404, jsonBody: { error: 'Item not found' } };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const validated = ${schemaName}.parse(resources[0]);
|
|
209
|
+
return { status: 200, jsonBody: validated };
|
|
210
|
+
} catch (error) {
|
|
211
|
+
${authCatchBlock} context.error(\`Error fetching item from \${containerName}:\`, error);
|
|
212
|
+
return { status: 500, jsonBody: { error: 'Failed to fetch item' } };
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
});`;
|
|
216
|
+
}
|
|
217
|
+
// --- TS update ハンドラー生成ヘルパー ---
|
|
218
|
+
function generateUpdateWithBinding(modelCamel, schemaName, writeGuard, authCatchBlock) {
|
|
219
|
+
return `// PUT /api/${modelCamel}/{id} - 更新
|
|
149
220
|
app.http('${modelCamel}-update', {
|
|
150
221
|
methods: ['PUT'],
|
|
151
222
|
route: '${modelCamel}/{id}',
|
|
@@ -206,9 +277,63 @@ ${authCatchBlock} context.error(\`Error updating item in \${containerName}:
|
|
|
206
277
|
return { status: 500, jsonBody: { error: 'Failed to update item' } };
|
|
207
278
|
}
|
|
208
279
|
},
|
|
209
|
-
})
|
|
280
|
+
});`;
|
|
281
|
+
}
|
|
282
|
+
function generateUpdateWithSdk(modelCamel, schemaName, partitionKeyField, writeGuard, authCatchBlock, sdkClientInit) {
|
|
283
|
+
return `// PUT /api/${modelCamel}/{id} - 更新 (custom partition key)
|
|
284
|
+
app.http('${modelCamel}-update', {
|
|
285
|
+
methods: ['PUT'],
|
|
286
|
+
route: '${modelCamel}/{id}',
|
|
287
|
+
authLevel: 'anonymous',
|
|
288
|
+
handler: async (request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> => {
|
|
289
|
+
try {${writeGuard}
|
|
290
|
+
const id = request.params.id;
|
|
291
|
+
if (!id) {
|
|
292
|
+
return { status: 400, jsonBody: { error: 'ID is required' } };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
${sdkClientInit}
|
|
296
|
+
|
|
297
|
+
const { resources } = await container.items.query({
|
|
298
|
+
query: 'SELECT * FROM c WHERE c.id = @id',
|
|
299
|
+
parameters: [{ name: '@id', value: id }],
|
|
300
|
+
}).fetchAll();
|
|
301
|
+
|
|
302
|
+
if (!resources || resources.length === 0) {
|
|
303
|
+
return { status: 404, jsonBody: { error: 'Item not found' } };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const existingDocument = resources[0];
|
|
307
|
+
const body = await request.json() as any;
|
|
308
|
+
const { createdAt, updatedAt, ...userData } = body;
|
|
309
|
+
|
|
310
|
+
const dataWithManagedFields = {
|
|
311
|
+
...userData,
|
|
312
|
+
id,
|
|
313
|
+
createdAt: existingDocument.createdAt,
|
|
314
|
+
updatedAt: new Date().toISOString(),
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const result = ${schemaName}.safeParse(dataWithManagedFields);
|
|
318
|
+
|
|
319
|
+
if (!result.success) {
|
|
320
|
+
context.error('Validation failed:', result.error.issues);
|
|
321
|
+
return { status: 400, jsonBody: { error: 'Validation failed', details: result.error.issues } };
|
|
322
|
+
}
|
|
210
323
|
|
|
211
|
-
|
|
324
|
+
const pkValue = result.data.${partitionKeyField};
|
|
325
|
+
await container.items.upsert(result.data);
|
|
326
|
+
return { status: 200, jsonBody: result.data };
|
|
327
|
+
} catch (error) {
|
|
328
|
+
${authCatchBlock} context.error(\`Error updating item in \${containerName}:\`, error);
|
|
329
|
+
return { status: 500, jsonBody: { error: 'Failed to update item' } };
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
});`;
|
|
333
|
+
}
|
|
334
|
+
// --- TS delete ハンドラー生成ヘルパー ---
|
|
335
|
+
function generateDeleteIdPartition(modelCamel, writeGuard, authCatchBlock, sdkClientInit) {
|
|
336
|
+
return `// DELETE /api/${modelCamel}/{id} - 削除
|
|
212
337
|
app.http('${modelCamel}-delete', {
|
|
213
338
|
methods: ['DELETE'],
|
|
214
339
|
route: '${modelCamel}/{id}',
|
|
@@ -220,17 +345,7 @@ app.http('${modelCamel}-delete', {
|
|
|
220
345
|
return { status: 400, jsonBody: { error: 'ID is required' } };
|
|
221
346
|
}
|
|
222
347
|
|
|
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);
|
|
348
|
+
${sdkClientInit}
|
|
234
349
|
|
|
235
350
|
await container.item(id, id).delete();
|
|
236
351
|
context.log(\`Deleted item \${id} from \${containerName}\`);
|
|
@@ -244,14 +359,119 @@ ${authCatchBlock} context.error(\`Error deleting item from \${containerName
|
|
|
244
359
|
return { status: 500, jsonBody: { error: 'Failed to delete item' } };
|
|
245
360
|
}
|
|
246
361
|
},
|
|
247
|
-
})
|
|
248
|
-
|
|
362
|
+
});`;
|
|
363
|
+
}
|
|
364
|
+
function generateDeleteCustomPartition(modelCamel, partitionKeyField, writeGuard, authCatchBlock, sdkClientInit) {
|
|
365
|
+
return `// DELETE /api/${modelCamel}/{id} - 削除 (custom partition key)
|
|
366
|
+
app.http('${modelCamel}-delete', {
|
|
367
|
+
methods: ['DELETE'],
|
|
368
|
+
route: '${modelCamel}/{id}',
|
|
369
|
+
authLevel: 'anonymous',
|
|
370
|
+
handler: async (request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> => {
|
|
371
|
+
try {${writeGuard}
|
|
372
|
+
const id = request.params.id;
|
|
373
|
+
if (!id) {
|
|
374
|
+
return { status: 400, jsonBody: { error: 'ID is required' } };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
${sdkClientInit}
|
|
378
|
+
|
|
379
|
+
// パーティションキー値を取得するためにドキュメントを読み取り
|
|
380
|
+
const { resources } = await container.items.query({
|
|
381
|
+
query: 'SELECT * FROM c WHERE c.id = @id',
|
|
382
|
+
parameters: [{ name: '@id', value: id }],
|
|
383
|
+
}).fetchAll();
|
|
384
|
+
|
|
385
|
+
if (!resources || resources.length === 0) {
|
|
386
|
+
return { status: 404, jsonBody: { error: 'Item not found' } };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const pkValue = resources[0].${partitionKeyField};
|
|
390
|
+
await container.item(id, pkValue).delete();
|
|
391
|
+
context.log(\`Deleted item \${id} from \${containerName}\`);
|
|
392
|
+
|
|
393
|
+
return { status: 204 };
|
|
394
|
+
} catch (error: any) {
|
|
395
|
+
if (error.code === 404) {
|
|
396
|
+
return { status: 404, jsonBody: { error: 'Item not found' } };
|
|
397
|
+
}
|
|
398
|
+
${authCatchBlock} context.error(\`Error deleting item from \${containerName}:\`, error);
|
|
399
|
+
return { status: 500, jsonBody: { error: 'Failed to delete item' } };
|
|
400
|
+
}
|
|
401
|
+
},
|
|
402
|
+
});`;
|
|
249
403
|
}
|
|
250
|
-
function generateCSharpAzureFunctionsCRUD(model) {
|
|
404
|
+
function generateCSharpAzureFunctionsCRUD(model, authPolicy) {
|
|
251
405
|
const modelName = model.name;
|
|
252
406
|
const modelCamel = (0, model_parser_1.toCamelCase)(modelName);
|
|
253
407
|
const className = `${modelName}Functions`;
|
|
254
|
-
const containerName = `${modelName}s`;
|
|
408
|
+
const containerName = modelName.endsWith('s') ? modelName : `${modelName}s`;
|
|
409
|
+
const partitionKeyPath = model.partitionKey;
|
|
410
|
+
const partitionKeyField = partitionKeyPath.slice(1);
|
|
411
|
+
const isIdPartition = partitionKeyField === 'id';
|
|
412
|
+
const hasAuth = !!authPolicy;
|
|
413
|
+
const authUsing = hasAuth ? 'using Functions.Auth;\n' : '';
|
|
414
|
+
const readGuard = hasAuth ? `\n${(0, auth_generator_1.generateAuthGuardCSharp)(authPolicy, 'read')}\n` : '';
|
|
415
|
+
const writeGuard = hasAuth ? `\n${(0, auth_generator_1.generateAuthGuardCSharp)(authPolicy, 'write')}\n` : '';
|
|
416
|
+
// PK値の取得ロジック(create 用)
|
|
417
|
+
const createPkExpr = isIdPartition
|
|
418
|
+
? 'id'
|
|
419
|
+
: `payload["${partitionKeyField}"]?.GetValue<string>() ?? throw new InvalidOperationException("Partition key field '${partitionKeyField}' is required.")`;
|
|
420
|
+
// ReadCosmosItemAsync: PK=/id の場合は直接読み取り、それ以外はクエリ
|
|
421
|
+
const readItemMethod = isIdPartition
|
|
422
|
+
? ` private static async Task<JsonObject> ReadCosmosItemAsync(Container container, string id)
|
|
423
|
+
{
|
|
424
|
+
var response = await container.ReadItemStreamAsync(id, new PartitionKey(id));
|
|
425
|
+
if (response.StatusCode == HttpStatusCode.NotFound)
|
|
426
|
+
{
|
|
427
|
+
throw new CosmosException("Item not found", HttpStatusCode.NotFound, 0, response.Headers.ActivityId, response.Headers.RequestCharge);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (!response.IsSuccessStatusCode)
|
|
431
|
+
{
|
|
432
|
+
throw new InvalidOperationException($"Cosmos read failed with status {(int)response.StatusCode}.");
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
using var document = await JsonDocument.ParseAsync(response.Content);
|
|
436
|
+
return JsonNode.Parse(document.RootElement.GetRawText())?.AsObject()
|
|
437
|
+
?? throw new JsonException("Cosmos item payload must be a JSON object.");
|
|
438
|
+
}`
|
|
439
|
+
: ` private static async Task<JsonObject> ReadCosmosItemAsync(Container container, string id)
|
|
440
|
+
{
|
|
441
|
+
var query = new QueryDefinition("SELECT * FROM c WHERE c.id = @id")
|
|
442
|
+
.WithParameter("@id", id);
|
|
443
|
+
using var iterator = container.GetItemQueryStreamIterator(query);
|
|
444
|
+
|
|
445
|
+
while (iterator.HasMoreResults)
|
|
446
|
+
{
|
|
447
|
+
var page = await iterator.ReadNextAsync();
|
|
448
|
+
if (!page.IsSuccessStatusCode)
|
|
449
|
+
{
|
|
450
|
+
throw new InvalidOperationException($"Cosmos query failed with status {(int)page.StatusCode}.");
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
using var document = await JsonDocument.ParseAsync(page.Content);
|
|
454
|
+
if (document.RootElement.TryGetProperty("Documents", out var documents))
|
|
455
|
+
{
|
|
456
|
+
foreach (var item in documents.EnumerateArray())
|
|
457
|
+
{
|
|
458
|
+
return JsonNode.Parse(item.GetRawText())?.AsObject()
|
|
459
|
+
?? throw new JsonException("Cosmos item payload must be a JSON object.");
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
throw new CosmosException("Item not found", HttpStatusCode.NotFound, 0, "", 0);
|
|
465
|
+
}`;
|
|
466
|
+
// Replace / Delete の PK 解決ロジック
|
|
467
|
+
const replacePkExpr = isIdPartition
|
|
468
|
+
? 'new PartitionKey(id)'
|
|
469
|
+
: `new PartitionKey(payload["${partitionKeyField}"]?.GetValue<string>())`;
|
|
470
|
+
const deleteLogic = isIdPartition
|
|
471
|
+
? ` await container.DeleteItemAsync<JsonObject>(id, new PartitionKey(id));`
|
|
472
|
+
: ` var existing = await ReadCosmosItemAsync(container, id);
|
|
473
|
+
var pkValue = existing["${partitionKeyField}"]?.GetValue<string>();
|
|
474
|
+
await container.DeleteItemAsync<JsonObject>(id, new PartitionKey(pkValue));`;
|
|
255
475
|
return `using System.Net;
|
|
256
476
|
using System.Text;
|
|
257
477
|
using System.Text.Json;
|
|
@@ -261,7 +481,7 @@ using Microsoft.Azure.Cosmos;
|
|
|
261
481
|
using Microsoft.Azure.Functions.Worker;
|
|
262
482
|
using Microsoft.Azure.Functions.Worker.Http;
|
|
263
483
|
using Microsoft.Extensions.Logging;
|
|
264
|
-
|
|
484
|
+
${authUsing}
|
|
265
485
|
namespace SwallowKit.Functions;
|
|
266
486
|
|
|
267
487
|
public sealed class ${className}
|
|
@@ -344,23 +564,7 @@ public sealed class ${className}
|
|
|
344
564
|
private static Stream CreateJsonStream(JsonObject payload) =>
|
|
345
565
|
new MemoryStream(Encoding.UTF8.GetBytes(payload.ToJsonString()));
|
|
346
566
|
|
|
347
|
-
|
|
348
|
-
{
|
|
349
|
-
var response = await container.ReadItemStreamAsync(id, new PartitionKey(id));
|
|
350
|
-
if (response.StatusCode == HttpStatusCode.NotFound)
|
|
351
|
-
{
|
|
352
|
-
throw new CosmosException("Item not found", HttpStatusCode.NotFound, 0, response.Headers.ActivityId, response.Headers.RequestCharge);
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
if (!response.IsSuccessStatusCode)
|
|
356
|
-
{
|
|
357
|
-
throw new InvalidOperationException($"Cosmos read failed with status {(int)response.StatusCode}.");
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
using var document = await JsonDocument.ParseAsync(response.Content);
|
|
361
|
-
return JsonNode.Parse(document.RootElement.GetRawText())?.AsObject()
|
|
362
|
-
?? throw new JsonException("Cosmos item payload must be a JSON object.");
|
|
363
|
-
}
|
|
567
|
+
${readItemMethod}
|
|
364
568
|
|
|
365
569
|
private static async Task<HttpResponseData> WriteJsonAsync(HttpRequestData request, HttpStatusCode status, object payload)
|
|
366
570
|
{
|
|
@@ -374,7 +578,7 @@ public sealed class ${className}
|
|
|
374
578
|
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "${modelCamel}")] HttpRequestData request)
|
|
375
579
|
{
|
|
376
580
|
try
|
|
377
|
-
{
|
|
581
|
+
{${readGuard}
|
|
378
582
|
using var client = CreateCosmosClient();
|
|
379
583
|
var container = GetContainer(client);
|
|
380
584
|
using var iterator = container.GetItemQueryStreamIterator("SELECT * FROM c");
|
|
@@ -416,7 +620,7 @@ public sealed class ${className}
|
|
|
416
620
|
string id)
|
|
417
621
|
{
|
|
418
622
|
try
|
|
419
|
-
{
|
|
623
|
+
{${readGuard}
|
|
420
624
|
using var client = CreateCosmosClient();
|
|
421
625
|
var container = GetContainer(client);
|
|
422
626
|
var item = await ReadCosmosItemAsync(container, id);
|
|
@@ -438,7 +642,7 @@ public sealed class ${className}
|
|
|
438
642
|
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "${modelCamel}")] HttpRequestData request)
|
|
439
643
|
{
|
|
440
644
|
try
|
|
441
|
-
{
|
|
645
|
+
{${writeGuard}
|
|
442
646
|
var body = await ReadRequestBodyAsync(request);
|
|
443
647
|
var now = DateTimeOffset.UtcNow.ToString("O");
|
|
444
648
|
var id = body["id"]?.GetValue<string>() ?? Guid.NewGuid().ToString();
|
|
@@ -447,7 +651,8 @@ public sealed class ${className}
|
|
|
447
651
|
using var client = CreateCosmosClient();
|
|
448
652
|
var container = GetContainer(client);
|
|
449
653
|
using var stream = CreateJsonStream(payload);
|
|
450
|
-
var
|
|
654
|
+
var pkValue = ${createPkExpr};
|
|
655
|
+
var response = await container.CreateItemStreamAsync(stream, new PartitionKey(pkValue));
|
|
451
656
|
if (!response.IsSuccessStatusCode)
|
|
452
657
|
{
|
|
453
658
|
throw new InvalidOperationException($"Cosmos create failed with status {(int)response.StatusCode}.");
|
|
@@ -473,7 +678,7 @@ public sealed class ${className}
|
|
|
473
678
|
string id)
|
|
474
679
|
{
|
|
475
680
|
try
|
|
476
|
-
{
|
|
681
|
+
{${writeGuard}
|
|
477
682
|
using var client = CreateCosmosClient();
|
|
478
683
|
var container = GetContainer(client);
|
|
479
684
|
|
|
@@ -492,7 +697,7 @@ public sealed class ${className}
|
|
|
492
697
|
var payload = BuildManagedDocument(body, id, createdAt, DateTimeOffset.UtcNow.ToString("O"));
|
|
493
698
|
|
|
494
699
|
using var stream = CreateJsonStream(payload);
|
|
495
|
-
var response = await container.ReplaceItemStreamAsync(stream, id,
|
|
700
|
+
var response = await container.ReplaceItemStreamAsync(stream, id, ${replacePkExpr});
|
|
496
701
|
if (!response.IsSuccessStatusCode)
|
|
497
702
|
{
|
|
498
703
|
throw new InvalidOperationException($"Cosmos replace failed with status {(int)response.StatusCode}.");
|
|
@@ -518,10 +723,10 @@ public sealed class ${className}
|
|
|
518
723
|
string id)
|
|
519
724
|
{
|
|
520
725
|
try
|
|
521
|
-
{
|
|
726
|
+
{${writeGuard}
|
|
522
727
|
using var client = CreateCosmosClient();
|
|
523
728
|
var container = GetContainer(client);
|
|
524
|
-
|
|
729
|
+
${deleteLogic}
|
|
525
730
|
|
|
526
731
|
return request.CreateResponse(HttpStatusCode.NoContent);
|
|
527
732
|
}
|
|
@@ -538,11 +743,88 @@ public sealed class ${className}
|
|
|
538
743
|
}
|
|
539
744
|
`;
|
|
540
745
|
}
|
|
541
|
-
function generatePythonAzureFunctionsCRUD(model) {
|
|
746
|
+
function generatePythonAzureFunctionsCRUD(model, authPolicy) {
|
|
542
747
|
const modelName = model.name;
|
|
543
748
|
const modelCamel = (0, model_parser_1.toCamelCase)(modelName);
|
|
544
749
|
const modelSnake = (0, model_parser_1.toKebabCase)(modelName).replace(/-/g, "_");
|
|
545
|
-
const containerName = `${modelName}s`;
|
|
750
|
+
const containerName = modelName.endsWith('s') ? modelName : `${modelName}s`;
|
|
751
|
+
const partitionKeyPath = model.partitionKey;
|
|
752
|
+
const partitionKeyField = partitionKeyPath.slice(1);
|
|
753
|
+
const isIdPartition = partitionKeyField === 'id';
|
|
754
|
+
const hasAuth = !!authPolicy;
|
|
755
|
+
const authImport = hasAuth ? '\nfrom auth.jwt_helper import require_auth, require_roles, handle_auth_error\n' : '';
|
|
756
|
+
// generateAuthGuardPython outputs at 4-space indent; inside try: we need 8-space
|
|
757
|
+
const readGuardRaw = hasAuth ? (0, auth_generator_1.generateAuthGuardPython)(authPolicy, 'read') : '';
|
|
758
|
+
const writeGuardRaw = hasAuth ? (0, auth_generator_1.generateAuthGuardPython)(authPolicy, 'write') : '';
|
|
759
|
+
const readGuard = hasAuth ? '\n' + readGuardRaw.split('\n').map(l => ' ' + l).join('\n') : '';
|
|
760
|
+
const writeGuard = hasAuth ? '\n' + writeGuardRaw.split('\n').map(l => ' ' + l).join('\n') : '';
|
|
761
|
+
const authCatch = hasAuth ? `\n auth_err = handle_auth_error(exc)\n if auth_err:\n return auth_err` : '';
|
|
762
|
+
// getById / update の読み取りロジック
|
|
763
|
+
const getByIdRead = isIdPartition
|
|
764
|
+
? `container.read_item(item=item_id, partition_key=item_id)`
|
|
765
|
+
: `list(container.query_items(
|
|
766
|
+
query="SELECT * FROM c WHERE c.id = @id",
|
|
767
|
+
parameters=[{"name": "@id", "value": item_id}],
|
|
768
|
+
enable_cross_partition_query=True,
|
|
769
|
+
))`;
|
|
770
|
+
const getByIdBody = isIdPartition
|
|
771
|
+
? ` container = _get_container()
|
|
772
|
+
item = container.read_item(item=item_id, partition_key=item_id)
|
|
773
|
+
return _json_response(item, 200)`
|
|
774
|
+
: ` container = _get_container()
|
|
775
|
+
items = list(container.query_items(
|
|
776
|
+
query="SELECT * FROM c WHERE c.id = @id",
|
|
777
|
+
parameters=[{"name": "@id", "value": item_id}],
|
|
778
|
+
enable_cross_partition_query=True,
|
|
779
|
+
))
|
|
780
|
+
if not items:
|
|
781
|
+
return _json_response({"error": "${modelName} not found", "id": item_id}, 404)
|
|
782
|
+
return _json_response(items[0], 200)`;
|
|
783
|
+
const updateBody = isIdPartition
|
|
784
|
+
? ` container = _get_container()
|
|
785
|
+
existing = container.read_item(item=item_id, partition_key=item_id)
|
|
786
|
+
body = req.get_json()
|
|
787
|
+
payload = _build_managed_document(
|
|
788
|
+
body,
|
|
789
|
+
item_id,
|
|
790
|
+
existing.get("createdAt") or datetime.now(timezone.utc).isoformat(),
|
|
791
|
+
datetime.now(timezone.utc).isoformat(),
|
|
792
|
+
)
|
|
793
|
+
container.replace_item(item=item_id, body=payload)
|
|
794
|
+
return _json_response(payload, 200)`
|
|
795
|
+
: ` container = _get_container()
|
|
796
|
+
items = list(container.query_items(
|
|
797
|
+
query="SELECT * FROM c WHERE c.id = @id",
|
|
798
|
+
parameters=[{"name": "@id", "value": item_id}],
|
|
799
|
+
enable_cross_partition_query=True,
|
|
800
|
+
))
|
|
801
|
+
if not items:
|
|
802
|
+
return _json_response({"error": "${modelName} not found", "id": item_id}, 404)
|
|
803
|
+
existing = items[0]
|
|
804
|
+
body = req.get_json()
|
|
805
|
+
payload = _build_managed_document(
|
|
806
|
+
body,
|
|
807
|
+
item_id,
|
|
808
|
+
existing.get("createdAt") or datetime.now(timezone.utc).isoformat(),
|
|
809
|
+
datetime.now(timezone.utc).isoformat(),
|
|
810
|
+
)
|
|
811
|
+
container.replace_item(item=existing, body=payload)
|
|
812
|
+
return _json_response(payload, 200)`;
|
|
813
|
+
const deleteBody = isIdPartition
|
|
814
|
+
? ` container = _get_container()
|
|
815
|
+
container.delete_item(item=item_id, partition_key=item_id)
|
|
816
|
+
return func.HttpResponse(status_code=204)`
|
|
817
|
+
: ` container = _get_container()
|
|
818
|
+
items = list(container.query_items(
|
|
819
|
+
query="SELECT * FROM c WHERE c.id = @id",
|
|
820
|
+
parameters=[{"name": "@id", "value": item_id}],
|
|
821
|
+
enable_cross_partition_query=True,
|
|
822
|
+
))
|
|
823
|
+
if not items:
|
|
824
|
+
return _json_response({"error": "${modelName} not found", "id": item_id}, 404)
|
|
825
|
+
pk_value = items[0].get("${partitionKeyField}")
|
|
826
|
+
container.delete_item(item=item_id, partition_key=pk_value)
|
|
827
|
+
return func.HttpResponse(status_code=204)`;
|
|
546
828
|
return {
|
|
547
829
|
registration: `from blueprints.${modelSnake} import bp as ${modelSnake}_bp\napp.register_blueprint(${modelSnake}_bp)`,
|
|
548
830
|
blueprint: `import json
|
|
@@ -554,7 +836,7 @@ import os
|
|
|
554
836
|
import azure.functions as func
|
|
555
837
|
from azure.cosmos import CosmosClient, exceptions
|
|
556
838
|
from azure.identity import DefaultAzureCredential
|
|
557
|
-
|
|
839
|
+
${authImport}
|
|
558
840
|
bp = func.Blueprint()
|
|
559
841
|
CONTAINER_NAME = "${containerName}"
|
|
560
842
|
DATABASE_NAME = os.environ.get("COSMOS_DB_DATABASE_NAME", "AppDatabase")
|
|
@@ -596,7 +878,7 @@ def _build_managed_document(source: dict[str, Any], item_id: str, created_at: st
|
|
|
596
878
|
|
|
597
879
|
@bp.route(route="${modelCamel}", methods=["GET"])
|
|
598
880
|
def ${modelSnake}_get_all(req: func.HttpRequest) -> func.HttpResponse:
|
|
599
|
-
try
|
|
881
|
+
try:${readGuard}
|
|
600
882
|
container = _get_container()
|
|
601
883
|
items = list(
|
|
602
884
|
container.query_items(
|
|
@@ -605,26 +887,24 @@ def ${modelSnake}_get_all(req: func.HttpRequest) -> func.HttpResponse:
|
|
|
605
887
|
)
|
|
606
888
|
)
|
|
607
889
|
return _json_response(items, 200)
|
|
608
|
-
except Exception as exc
|
|
890
|
+
except Exception as exc:${authCatch}
|
|
609
891
|
return _json_response({"error": "Failed to fetch items", "details": str(exc)}, 500)
|
|
610
892
|
|
|
611
893
|
|
|
612
894
|
@bp.route(route="${modelCamel}/{id}", methods=["GET"])
|
|
613
895
|
def ${modelSnake}_get_by_id(req: func.HttpRequest) -> func.HttpResponse:
|
|
614
896
|
item_id = req.route_params.get("id")
|
|
615
|
-
try
|
|
616
|
-
|
|
617
|
-
item = container.read_item(item=item_id, partition_key=item_id)
|
|
618
|
-
return _json_response(item, 200)
|
|
897
|
+
try:${readGuard}
|
|
898
|
+
${getByIdBody}
|
|
619
899
|
except exceptions.CosmosResourceNotFoundError:
|
|
620
900
|
return _json_response({"error": "${modelName} not found", "id": item_id}, 404)
|
|
621
|
-
except Exception as exc
|
|
901
|
+
except Exception as exc:${authCatch}
|
|
622
902
|
return _json_response({"error": "Failed to fetch item", "id": item_id, "details": str(exc)}, 500)
|
|
623
903
|
|
|
624
904
|
|
|
625
905
|
@bp.route(route="${modelCamel}", methods=["POST"])
|
|
626
906
|
def ${modelSnake}_create(req: func.HttpRequest) -> func.HttpResponse:
|
|
627
|
-
try
|
|
907
|
+
try:${writeGuard}
|
|
628
908
|
body = req.get_json()
|
|
629
909
|
now = datetime.now(timezone.utc).isoformat()
|
|
630
910
|
item_id = body.get("id") or str(uuid4())
|
|
@@ -635,43 +915,31 @@ def ${modelSnake}_create(req: func.HttpRequest) -> func.HttpResponse:
|
|
|
635
915
|
return _json_response(payload, 201)
|
|
636
916
|
except ValueError:
|
|
637
917
|
return _json_response({"error": "Request body must be a JSON object."}, 400)
|
|
638
|
-
except Exception as exc
|
|
918
|
+
except Exception as exc:${authCatch}
|
|
639
919
|
return _json_response({"error": "Failed to create item", "details": str(exc)}, 500)
|
|
640
920
|
|
|
641
921
|
|
|
642
922
|
@bp.route(route="${modelCamel}/{id}", methods=["PUT"])
|
|
643
923
|
def ${modelSnake}_update(req: func.HttpRequest) -> func.HttpResponse:
|
|
644
924
|
item_id = req.route_params.get("id")
|
|
645
|
-
try
|
|
646
|
-
|
|
647
|
-
existing = container.read_item(item=item_id, partition_key=item_id)
|
|
648
|
-
body = req.get_json()
|
|
649
|
-
payload = _build_managed_document(
|
|
650
|
-
body,
|
|
651
|
-
item_id,
|
|
652
|
-
existing.get("createdAt") or datetime.now(timezone.utc).isoformat(),
|
|
653
|
-
datetime.now(timezone.utc).isoformat(),
|
|
654
|
-
)
|
|
655
|
-
container.replace_item(item=item_id, body=payload)
|
|
656
|
-
return _json_response(payload, 200)
|
|
925
|
+
try:${writeGuard}
|
|
926
|
+
${updateBody}
|
|
657
927
|
except exceptions.CosmosResourceNotFoundError:
|
|
658
928
|
return _json_response({"error": "${modelName} not found", "id": item_id}, 404)
|
|
659
929
|
except ValueError:
|
|
660
930
|
return _json_response({"error": "Request body must be a JSON object.", "id": item_id}, 400)
|
|
661
|
-
except Exception as exc
|
|
931
|
+
except Exception as exc:${authCatch}
|
|
662
932
|
return _json_response({"error": "Failed to update item", "id": item_id, "details": str(exc)}, 500)
|
|
663
933
|
|
|
664
934
|
|
|
665
935
|
@bp.route(route="${modelCamel}/{id}", methods=["DELETE"])
|
|
666
936
|
def ${modelSnake}_delete(req: func.HttpRequest) -> func.HttpResponse:
|
|
667
937
|
item_id = req.route_params.get("id")
|
|
668
|
-
try
|
|
669
|
-
|
|
670
|
-
container.delete_item(item=item_id, partition_key=item_id)
|
|
671
|
-
return func.HttpResponse(status_code=204)
|
|
938
|
+
try:${writeGuard}
|
|
939
|
+
${deleteBody}
|
|
672
940
|
except exceptions.CosmosResourceNotFoundError:
|
|
673
941
|
return _json_response({"error": "${modelName} not found", "id": item_id}, 404)
|
|
674
|
-
except Exception as exc
|
|
942
|
+
except Exception as exc:${authCatch}
|
|
675
943
|
return _json_response({"error": "Failed to delete item", "id": item_id, "details": str(exc)}, 500)
|
|
676
944
|
`,
|
|
677
945
|
};
|