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
@@ -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}s';
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
- // 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
- });
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
- // PUT /api/${modelCamel}/{id} - 更新
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
- // DELETE /api/${modelCamel}/{id} - 削除
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
- 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);
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
- private static async Task<JsonObject> ReadCosmosItemAsync(Container container, string id)
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 response = await container.CreateItemStreamAsync(stream, new PartitionKey(id));
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, new PartitionKey(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
- await container.DeleteItemAsync<JsonObject>(id, new PartitionKey(id));
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
- container = _get_container()
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
- container = _get_container()
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
- container = _get_container()
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
  };