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.
Files changed (43) hide show
  1. package/dist/__tests__/fixtures.d.ts.map +1 -1
  2. package/dist/__tests__/fixtures.js +1 -0
  3. package/dist/__tests__/fixtures.js.map +1 -1
  4. package/dist/cli/commands/add-auth.d.ts.map +1 -1
  5. package/dist/cli/commands/add-auth.js +85 -5
  6. package/dist/cli/commands/add-auth.js.map +1 -1
  7. package/dist/cli/commands/create-model.js +1 -1
  8. package/dist/cli/commands/create-model.js.map +1 -1
  9. package/dist/cli/commands/dev-seeds.js +5 -5
  10. package/dist/cli/commands/dev-seeds.js.map +1 -1
  11. package/dist/cli/commands/dev.js +64 -24
  12. package/dist/cli/commands/dev.js.map +1 -1
  13. package/dist/cli/commands/init.js +4 -4
  14. package/dist/cli/commands/scaffold.d.ts.map +1 -1
  15. package/dist/cli/commands/scaffold.js +61 -5
  16. package/dist/cli/commands/scaffold.js.map +1 -1
  17. package/dist/core/scaffold/auth-generator.d.ts +1 -1
  18. package/dist/core/scaffold/auth-generator.d.ts.map +1 -1
  19. package/dist/core/scaffold/auth-generator.js +17 -20
  20. package/dist/core/scaffold/auth-generator.js.map +1 -1
  21. package/dist/core/scaffold/functions-generator.d.ts +2 -2
  22. package/dist/core/scaffold/functions-generator.d.ts.map +1 -1
  23. package/dist/core/scaffold/functions-generator.js +375 -107
  24. package/dist/core/scaffold/functions-generator.js.map +1 -1
  25. package/dist/core/scaffold/model-parser.d.ts +7 -0
  26. package/dist/core/scaffold/model-parser.d.ts.map +1 -1
  27. package/dist/core/scaffold/model-parser.js +25 -0
  28. package/dist/core/scaffold/model-parser.js.map +1 -1
  29. package/package.json +3 -3
  30. package/src/__tests__/__snapshots__/functions-generator.test.ts.snap +694 -0
  31. package/src/__tests__/auth.test.ts +13 -13
  32. package/src/__tests__/fixtures.ts +1 -0
  33. package/src/__tests__/functions-generator.test.ts +136 -0
  34. package/src/__tests__/model-parser.test.ts +72 -0
  35. package/src/cli/commands/add-auth.ts +95 -6
  36. package/src/cli/commands/create-model.ts +1 -1
  37. package/src/cli/commands/dev-seeds.ts +5 -5
  38. package/src/cli/commands/dev.ts +67 -23
  39. package/src/cli/commands/init.ts +4 -4
  40. package/src/cli/commands/scaffold.ts +69 -10
  41. package/src/core/scaffold/auth-generator.ts +16 -19
  42. package/src/core/scaffold/functions-generator.ts +402 -108
  43. 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}s';
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
- // GET /api/${modelCamel}/{id} - ID指定取得
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
- // PUT /api/${modelCamel}/{id} - 更新
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
- // DELETE /api/${modelCamel}/{id} - 削除
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
- const { CosmosClient } = await import('@azure/cosmos');
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
- export function generateCSharpAzureFunctionsCRUD(model: ModelInfo): string {
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
- private static async Task<JsonObject> ReadCosmosItemAsync(Container container, string id)
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 response = await container.CreateItemStreamAsync(stream, new PartitionKey(id));
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, new PartitionKey(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
- await container.DeleteItemAsync<JsonObject>(id, new PartitionKey(id));
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
- container = _get_container()
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
- container = _get_container()
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
- container = _get_container()
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
  };