@wictorwilen/cocogen 1.0.0 → 1.0.11

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 (67) hide show
  1. package/README.md +88 -35
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +84 -14
  4. package/dist/cli.js.map +1 -1
  5. package/dist/init/init.d.ts.map +1 -1
  6. package/dist/init/init.js +302 -156
  7. package/dist/init/init.js.map +1 -1
  8. package/dist/init/templates/dotnet/AGENTS.md.ejs +20 -0
  9. package/dist/init/templates/dotnet/Core/ConnectorCore.cs.ejs +602 -0
  10. package/dist/init/templates/dotnet/Datasource/CsvItemSource.cs.ejs +12 -3
  11. package/dist/init/templates/dotnet/Datasource/IItemSource.cs.ejs +10 -4
  12. package/dist/init/templates/dotnet/Generated/Constants.cs.ejs +5 -1
  13. package/dist/init/templates/dotnet/Generated/CsvParser.cs.ejs +61 -0
  14. package/dist/init/templates/dotnet/Generated/FromCsvRow.cs.ejs +11 -3
  15. package/dist/init/templates/dotnet/Generated/ItemPayload.cs.ejs +13 -3
  16. package/dist/init/templates/dotnet/Generated/Model.cs.ejs +5 -22
  17. package/dist/init/templates/dotnet/Generated/PropertyTransformBase.cs.ejs +45 -0
  18. package/dist/init/templates/dotnet/Generated/SchemaPayload.cs.ejs +9 -1
  19. package/dist/init/templates/dotnet/Program.commandline.cs.ejs +76 -278
  20. package/dist/init/templates/dotnet/PropertyTransform.cs.ejs +10 -0
  21. package/dist/init/templates/dotnet/README.md.ejs +6 -7
  22. package/dist/init/templates/dotnet/appsettings.json.ejs +1 -1
  23. package/dist/init/templates/ts/.env.example.ejs +1 -1
  24. package/dist/init/templates/ts/AGENTS.md.ejs +20 -0
  25. package/dist/init/templates/ts/README.md.ejs +6 -7
  26. package/dist/init/templates/ts/src/cli.ts.ejs +85 -173
  27. package/dist/init/templates/ts/src/core/connectorCore.ts.ejs +384 -0
  28. package/dist/init/templates/ts/src/datasource/csvItemSource.ts.ejs +12 -4
  29. package/dist/init/templates/ts/src/datasource/itemSource.ts.ejs +12 -5
  30. package/dist/init/templates/ts/src/generated/constants.ts.ejs +16 -0
  31. package/dist/init/templates/ts/src/generated/csv.ts.ejs +10 -0
  32. package/dist/init/templates/ts/src/generated/fromCsvRow.ts.ejs +12 -37
  33. package/dist/init/templates/ts/src/generated/index.ts.ejs +3 -0
  34. package/dist/init/templates/ts/src/generated/itemPayload.ts.ejs +12 -3
  35. package/dist/init/templates/ts/src/generated/model.ts.ejs +3 -11
  36. package/dist/init/templates/ts/src/generated/propertyTransformBase.ts.ejs +40 -0
  37. package/dist/init/templates/ts/src/generated/schemaPayload.ts.ejs +3 -0
  38. package/dist/init/templates/ts/src/index.ts.ejs +4 -1
  39. package/dist/init/templates/ts/src/propertyTransform.ts.ejs +16 -0
  40. package/dist/ir.d.ts +2 -0
  41. package/dist/ir.d.ts.map +1 -1
  42. package/dist/tsp/init-tsp.d.ts.map +1 -1
  43. package/dist/tsp/init-tsp.js +50 -7
  44. package/dist/tsp/init-tsp.js.map +1 -1
  45. package/dist/tsp/loader.d.ts.map +1 -1
  46. package/dist/tsp/loader.js +23 -9
  47. package/dist/tsp/loader.js.map +1 -1
  48. package/dist/typespec/decorators.d.ts +1 -0
  49. package/dist/typespec/decorators.d.ts.map +1 -1
  50. package/dist/typespec/decorators.js +7 -2
  51. package/dist/typespec/decorators.js.map +1 -1
  52. package/dist/typespec/state.d.ts +2 -0
  53. package/dist/typespec/state.d.ts.map +1 -1
  54. package/dist/typespec/state.js +1 -0
  55. package/dist/typespec/state.js.map +1 -1
  56. package/dist/validate/validator.d.ts.map +1 -1
  57. package/dist/validate/validator.js +127 -14
  58. package/dist/validate/validator.js.map +1 -1
  59. package/package.json +2 -1
  60. package/typespec/main.tsp +6 -2
  61. package/dist/init/templates/dotnet/Generated/PersonEntityDefaults.cs.ejs +0 -48
  62. package/dist/init/templates/dotnet/Generated/PropertyTransforms.cs.ejs +0 -22
  63. package/dist/init/templates/dotnet/PersonEntityOverrides.cs.ejs +0 -49
  64. package/dist/init/templates/dotnet/Program.cs.ejs +0 -487
  65. package/dist/init/templates/ts/src/generated/personEntityDefaults.ts.ejs +0 -33
  66. package/dist/init/templates/ts/src/generated/propertyTransforms.ts.ejs +0 -23
  67. package/dist/init/templates/ts/src/personEntityOverrides.ts.ejs +0 -36
@@ -0,0 +1,602 @@
1
+ using Azure.Core;
2
+ using Azure.Identity;
3
+ <% if (graphApiVersion === "beta") { -%>
4
+ using Microsoft.Graph.Beta;
5
+ using Microsoft.Graph.Beta.Models.ExternalConnectors;
6
+ using Microsoft.Graph.Beta.Models.ODataErrors;
7
+ <% } else { -%>
8
+ using Microsoft.Graph;
9
+ using Microsoft.Graph.Models.ExternalConnectors;
10
+ using Microsoft.Graph.Models.ODataErrors;
11
+ <% } -%>
12
+ using Microsoft.Kiota.Abstractions;
13
+ using Microsoft.Kiota.Abstractions.Serialization;
14
+ using System.CommandLine;
15
+ using System.Net.Http.Headers;
16
+ using System.Text;
17
+ using System.Text.Json;
18
+
19
+ using <%= namespaceName %>.Datasource;
20
+ using <%= schemaNamespace %>;
21
+
22
+ namespace <%= namespaceName %>.Core;
23
+
24
+ /// <summary>
25
+ /// Reusable connector core for provisioning, ingestion, and deletion.
26
+ /// </summary>
27
+ public sealed class ConnectorCore
28
+ {
29
+ private readonly GraphServiceClient? _graph;
30
+ private readonly ClientSecretCredential? _credential;
31
+ private readonly string _connectionId;
32
+ private readonly string _connectionName;
33
+ private readonly string _connectionDescription;
34
+ private readonly string? _contentCategory;
35
+ private readonly string? _profileSourceWebUrl;
36
+ private readonly string? _profileSourceDisplayName;
37
+ private readonly string? _profileSourcePriority;
38
+
39
+ public ConnectorCore(
40
+ GraphServiceClient? graph,
41
+ ClientSecretCredential? credential,
42
+ string connectionId,
43
+ string connectionName,
44
+ string connectionDescription,
45
+ string? contentCategory,
46
+ string? profileSourceWebUrl,
47
+ string? profileSourceDisplayName,
48
+ string? profileSourcePriority)
49
+ {
50
+ _graph = graph;
51
+ _credential = credential;
52
+ _connectionId = connectionId;
53
+ _connectionName = connectionName;
54
+ _connectionDescription = connectionDescription;
55
+ _contentCategory = contentCategory;
56
+ _profileSourceWebUrl = profileSourceWebUrl;
57
+ _profileSourceDisplayName = profileSourceDisplayName;
58
+ _profileSourcePriority = profileSourcePriority;
59
+ }
60
+
61
+ /// <summary>
62
+ /// Provision the connection and schema (and profile source for people connectors).
63
+ /// </summary>
64
+ public async Task ProvisionAsync()
65
+ {
66
+ EnsureGraph();
67
+ await EnsureConnectionAsync();
68
+ await PatchSchemaAsync();
69
+ if (HasProfileSource())
70
+ {
71
+ <% if (isPeopleConnector) { -%>
72
+ await RegisterProfileSourceInternalAsync(_connectionId);
73
+ <% } else { -%>
74
+ throw new InvalidOperationException("Profile source registration is only supported for people connectors.");
75
+ <% } -%>
76
+ }
77
+ }
78
+
79
+ /// <summary>
80
+ /// Delete the external connection.
81
+ /// </summary>
82
+ public async Task DeleteConnectionAsync()
83
+ {
84
+ EnsureGraph();
85
+
86
+ if (HasProfileSource())
87
+ {
88
+ <% if (isPeopleConnector) { -%>
89
+ await UnregisterProfileSourceInternalAsync(_connectionId);
90
+ <% } else { -%>
91
+ throw new InvalidOperationException("Profile source registration is only supported for people connectors.");
92
+ <% } -%>
93
+ }
94
+
95
+ try
96
+ {
97
+ await _graph!.External.Connections[_connectionId].DeleteAsync();
98
+ }
99
+ catch (ApiException ex) when (IsStatus(ex, 404))
100
+ {
101
+ // Already deleted.
102
+ }
103
+ }
104
+
105
+ /// <summary>
106
+ /// Upsert a single item into the external connection.
107
+ /// </summary>
108
+ public async Task PutItemAsync(<%= itemTypeName %> item, bool verbose)
109
+ {
110
+ EnsureGraph();
111
+
112
+ var itemId = ItemPayload.GetItemId(item);
113
+ var externalItem = ItemPayload.ToExternalItem(item);
114
+ if (verbose)
115
+ {
116
+ var url = $"{_graph!.RequestAdapter.BaseUrl ?? SchemaConstants.GraphBaseUrl}/external/connections/{_connectionId}/items/{Uri.EscapeDataString(itemId)}";
117
+ Console.WriteLine("verbose: PUT " + url);
118
+ Console.WriteLine("verbose: payload " + JsonSerializer.Serialize(externalItem, new JsonSerializerOptions { WriteIndented = true }));
119
+ }
120
+
121
+ var requestInfo = new RequestInformation
122
+ {
123
+ HttpMethod = Method.PUT,
124
+ UrlTemplate = "{+baseurl}/external/connections/{connectionId}/items/{itemId}",
125
+ PathParameters = new Dictionary<string, object>
126
+ {
127
+ { "baseurl", _graph!.RequestAdapter.BaseUrl ?? SchemaConstants.GraphBaseUrl },
128
+ { "connectionId", _connectionId },
129
+ { "itemId", itemId },
130
+ },
131
+ };
132
+
133
+ requestInfo.Headers.Add("Accept", "application/json");
134
+ requestInfo.SetContentFromParsable(_graph!.RequestAdapter, "application/json", externalItem);
135
+
136
+ var errorMapping = new Dictionary<string, ParsableFactory<IParsable>>
137
+ {
138
+ { "4XX", ODataError.CreateFromDiscriminatorValue },
139
+ { "5XX", ODataError.CreateFromDiscriminatorValue },
140
+ };
141
+
142
+ await RetryAsync(
143
+ () => _graph!.RequestAdapter.SendAsync<ExternalItem>(
144
+ requestInfo,
145
+ ExternalItem.CreateFromDiscriminatorValue,
146
+ errorMapping
147
+ ),
148
+ ex => ex.ResponseStatusCode,
149
+ "PUT item"
150
+ );
151
+ }
152
+
153
+ /// <summary>
154
+ /// Ingest items from a datasource.
155
+ /// </summary>
156
+ public async Task IngestAsync(IItemSource source, bool dryRun, int? limit, bool verbose)
157
+ {
158
+ var count = 0;
159
+ await foreach (var item in source.GetItemsAsync())
160
+ {
161
+ if (limit.HasValue && count >= limit.Value)
162
+ break;
163
+
164
+ if (!dryRun)
165
+ {
166
+ var itemId = ItemPayload.GetItemId(item);
167
+ Console.WriteLine($"info: ingesting item {count + 1} (id={itemId})");
168
+ try
169
+ {
170
+ await PutItemAsync(item, verbose);
171
+ Console.WriteLine($"ok: ingested item {count + 1} (id={itemId})");
172
+ }
173
+ catch (Exception)
174
+ {
175
+ Console.Error.WriteLine($"error: failed item {count + 1} (id={itemId})");
176
+ throw;
177
+ }
178
+ }
179
+ else if (verbose)
180
+ {
181
+ var itemId = ItemPayload.GetItemId(item);
182
+ Console.WriteLine($"verbose: DRY RUN item {count + 1} (id={itemId}) payload " + JsonSerializer.Serialize(ItemPayload.ToExternalItem(item), new JsonSerializerOptions { WriteIndented = true }));
183
+ }
184
+
185
+ count++;
186
+ }
187
+
188
+ Console.WriteLine($"ok: ingested {count} item(s)");
189
+ }
190
+
191
+ private void EnsureGraph()
192
+ {
193
+ if (_graph is null)
194
+ {
195
+ throw new InvalidOperationException("Graph client is required for this operation.");
196
+ }
197
+ }
198
+
199
+ private bool HasProfileSource()
200
+ {
201
+ return !string.IsNullOrWhiteSpace(_profileSourceWebUrl);
202
+ }
203
+
204
+ private async Task EnsureConnectionAsync()
205
+ {
206
+ try
207
+ {
208
+ await RetryAsync(
209
+ () => _graph!.External.Connections[_connectionId].GetAsync(),
210
+ ex => ex.ResponseStatusCode,
211
+ "GET connection"
212
+ );
213
+ return;
214
+ }
215
+ catch (ApiException ex) when (IsStatus(ex, 404))
216
+ {
217
+ // Create below.
218
+ }
219
+
220
+ var connection = new ExternalConnection
221
+ {
222
+ Id = _connectionId,
223
+ Name = _connectionName,
224
+ Description = _connectionDescription,
225
+ };
226
+
227
+ if (!string.IsNullOrWhiteSpace(_contentCategory))
228
+ {
229
+ connection.AdditionalData ??= new Dictionary<string, object>();
230
+ connection.AdditionalData["contentCategory"] = _contentCategory!;
231
+ }
232
+
233
+ await RetryAsync(
234
+ () => _graph!.External.Connections.PostAsync(connection),
235
+ ex => ex.ResponseStatusCode,
236
+ "POST connection"
237
+ );
238
+ }
239
+
240
+ private async Task PatchSchemaAsync()
241
+ {
242
+ var schema = SchemaPayload.BuildSchema();
243
+ await RetryAsync(
244
+ () => _graph!.External.Connections[_connectionId].Schema.PatchAsync(schema),
245
+ ex => ex.ResponseStatusCode,
246
+ "PATCH schema"
247
+ );
248
+
249
+ await WaitForSchemaReadyAsync();
250
+ }
251
+
252
+ private bool IsStatus(ApiException ex, int statusCode)
253
+ {
254
+ return ex.ResponseStatusCode is int sc && sc == statusCode;
255
+ }
256
+
257
+ private async Task RetryAsync(Func<Task> action, Func<ApiException, int?>? getStatusCode, string operation)
258
+ {
259
+ for (var attempt = 0; attempt <= MaxRetries; attempt++)
260
+ {
261
+ try
262
+ {
263
+ await action();
264
+ return;
265
+ }
266
+ catch (ApiException ex)
267
+ {
268
+ int? status = getStatusCode?.Invoke(ex) ?? ex.ResponseStatusCode;
269
+ if (status is null || !ShouldRetry(status.Value) || attempt == MaxRetries)
270
+ {
271
+ throw;
272
+ }
273
+
274
+ var retryAfterMs = ParseRetryAfterMs(ex.ResponseHeaders);
275
+ var delay = ComputeDelayMs(attempt, retryAfterMs);
276
+ Console.WriteLine($"warn: throttled (HTTP {status}) for {operation}, retrying in {delay}ms");
277
+ await Task.Delay(delay);
278
+ }
279
+ }
280
+ }
281
+
282
+ private static bool ShouldRetry(int statusCode) => statusCode is 429 or 503 or 504;
283
+
284
+ private static int? ParseRetryAfterMs(IDictionary<string, IEnumerable<string>>? headers)
285
+ {
286
+ if (headers is null) return null;
287
+ if (!headers.TryGetValue("Retry-After", out var values)) return null;
288
+
289
+ string? value = null;
290
+ foreach (var entry in values)
291
+ {
292
+ value = entry;
293
+ break;
294
+ }
295
+ if (string.IsNullOrWhiteSpace(value)) return null;
296
+
297
+ if (int.TryParse(value, out var seconds))
298
+ {
299
+ return Math.Max(0, seconds * 1000);
300
+ }
301
+
302
+ if (DateTimeOffset.TryParse(value, out var date))
303
+ {
304
+ var delta = date - DateTimeOffset.UtcNow;
305
+ return Math.Max(0, (int)delta.TotalMilliseconds);
306
+ }
307
+
308
+ return null;
309
+ }
310
+
311
+ private static int ComputeDelayMs(int attempt, int? retryAfterMs)
312
+ {
313
+ if (retryAfterMs.HasValue) return Math.Min(retryAfterMs.Value, MaxDelayMs);
314
+ var exp = Math.Min(MaxDelayMs, BaseDelayMs * (int)Math.Pow(2, attempt));
315
+ var jitter = Random.Shared.Next(0, 250);
316
+ return Math.Min(MaxDelayMs, exp + jitter);
317
+ }
318
+
319
+ private const int MaxRetries = 6;
320
+ private const int BaseDelayMs = 1000;
321
+ private const int MaxDelayMs = 30000;
322
+
323
+ <% if (isPeopleConnector) { -%>
324
+ /// <summary>
325
+ /// Register the connection as a people profile source.
326
+ /// </summary>
327
+ public async Task RegisterProfileSourceAsync()
328
+ {
329
+ if (!HasProfileSource())
330
+ throw new InvalidOperationException("Profile source settings are missing.");
331
+
332
+ await RegisterProfileSourceInternalAsync(_connectionId);
333
+ }
334
+
335
+ /// <summary>
336
+ /// Remove the connection from people profile source registrations.
337
+ /// </summary>
338
+ public async Task UnregisterProfileSourceAsync()
339
+ {
340
+ if (!HasProfileSource())
341
+ throw new InvalidOperationException("Profile source settings are missing.");
342
+
343
+ await UnregisterProfileSourceInternalAsync(_connectionId);
344
+ }
345
+
346
+ private async Task<string> GetAccessTokenAsync(ClientSecretCredential credential)
347
+ {
348
+ var token = await credential.GetTokenAsync(
349
+ new TokenRequestContext(new[] { "https://graph.microsoft.com/.default" })
350
+ );
351
+ return token.Token;
352
+ }
353
+
354
+ private async Task<HttpResponseMessage> GraphRequestAsync(ClientSecretCredential credential, HttpMethod method, string url, object? body = null)
355
+ {
356
+ using var http = new HttpClient();
357
+ var token = await GetAccessTokenAsync(credential);
358
+ http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
359
+
360
+ var request = new HttpRequestMessage(method, url);
361
+ if (body is not null)
362
+ {
363
+ var json = JsonSerializer.Serialize(body);
364
+ request.Content = new StringContent(json, Encoding.UTF8, "application/json");
365
+ }
366
+
367
+ return await http.SendAsync(request);
368
+ }
369
+
370
+ private async Task RegisterProfileSourceInternalAsync(string connectionId)
371
+ {
372
+ if (_credential is null)
373
+ throw new InvalidOperationException("Credential required for profile source registration.");
374
+
375
+ if (await ProfileSourceExistsAsync(connectionId))
376
+ return;
377
+
378
+ var webUrl = _profileSourceWebUrl ?? string.Empty;
379
+ var displayName = _profileSourceDisplayName ?? _connectionName;
380
+ var priority = _profileSourcePriority ?? "first";
381
+
382
+ var payload = new Dictionary<string, object?>
383
+ {
384
+ ["sourceId"] = connectionId,
385
+ ["displayName"] = displayName,
386
+ ["webUrl"] = webUrl,
387
+ };
388
+
389
+ var create = await GraphRequestAsync(
390
+ _credential,
391
+ HttpMethod.Post,
392
+ $"{SchemaConstants.GraphBaseUrl}/admin/people/profileSources",
393
+ payload
394
+ );
395
+
396
+ if (!create.IsSuccessStatusCode && create.StatusCode != System.Net.HttpStatusCode.Conflict)
397
+ {
398
+ var text = await create.Content.ReadAsStringAsync();
399
+ throw new InvalidOperationException($"Failed to register profile source (HTTP {(int)create.StatusCode}): {text}");
400
+ }
401
+
402
+ var sourceUrl = $"{SchemaConstants.GraphBaseUrl}/admin/people/profileSources(sourceId='{connectionId}')";
403
+ await UpdateProfileSourcePrecedenceAsync(_credential, sourceUrl, include: true, priority: priority);
404
+ }
405
+
406
+ private async Task UnregisterProfileSourceInternalAsync(string connectionId)
407
+ {
408
+ if (_credential is null)
409
+ throw new InvalidOperationException("Credential required for profile source registration.");
410
+
411
+ var sourceUrl = $"{SchemaConstants.GraphBaseUrl}/admin/people/profileSources(sourceId='{connectionId}')";
412
+
413
+ await UpdateProfileSourcePrecedenceAsync(_credential, sourceUrl, include: false, priority: "first");
414
+
415
+ var res = await GraphRequestAsync(
416
+ _credential,
417
+ HttpMethod.Delete,
418
+ $"{SchemaConstants.GraphBaseUrl}/admin/people/profileSources(sourceId='{connectionId}')"
419
+ );
420
+
421
+ if (!res.IsSuccessStatusCode && res.StatusCode != System.Net.HttpStatusCode.NotFound)
422
+ {
423
+ var text = await res.Content.ReadAsStringAsync();
424
+ throw new InvalidOperationException($"Failed to delete profile source (HTTP {(int)res.StatusCode}): {text}");
425
+ }
426
+ }
427
+
428
+ private async Task UpdateProfileSourcePrecedenceAsync(ClientSecretCredential credential, string sourceUrl, bool include, string priority)
429
+ {
430
+ var res = await GraphRequestAsync(
431
+ credential,
432
+ HttpMethod.Get,
433
+ $"{SchemaConstants.GraphBaseUrl}/admin/people/profilePropertySettings"
434
+ );
435
+
436
+ if (!res.IsSuccessStatusCode)
437
+ {
438
+ var text = await res.Content.ReadAsStringAsync();
439
+ throw new InvalidOperationException($"Failed to list profile property settings (HTTP {(int)res.StatusCode}): {text}");
440
+ }
441
+
442
+ var json = await res.Content.ReadAsStringAsync();
443
+ using var doc = JsonDocument.Parse(json);
444
+ if (!doc.RootElement.TryGetProperty("value", out var values) || values.ValueKind != JsonValueKind.Array)
445
+ return;
446
+
447
+ foreach (var entry in values.EnumerateArray())
448
+ {
449
+ if (!entry.TryGetProperty("id", out var idProp) || idProp.ValueKind != JsonValueKind.String)
450
+ continue;
451
+
452
+ var id = idProp.GetString() ?? string.Empty;
453
+ if (string.IsNullOrWhiteSpace(id)) continue;
454
+
455
+ List<string> existing = new();
456
+ if (entry.TryGetProperty("prioritizedSourceUrls", out var urls) && urls.ValueKind == JsonValueKind.Array)
457
+ {
458
+ foreach (var url in urls.EnumerateArray())
459
+ {
460
+ if (url.ValueKind == JsonValueKind.String)
461
+ existing.Add(url.GetString() ?? "");
462
+ }
463
+ }
464
+
465
+ existing = DeduplicateUrls(existing).Where((u) => !string.IsNullOrWhiteSpace(u) && u != sourceUrl).ToList();
466
+ var updated = existing;
467
+ if (include)
468
+ {
469
+ updated = priority == "last"
470
+ ? DeduplicateUrls(existing.Concat(new[] { sourceUrl }).ToList())
471
+ : DeduplicateUrls(new[] { sourceUrl }.Concat(existing).ToList());
472
+ }
473
+
474
+ var patch = new Dictionary<string, object?>
475
+ {
476
+ ["@odata.type"] = "#microsoft.graph.profilePropertySetting",
477
+ ["prioritizedSourceUrls"] = updated,
478
+ };
479
+
480
+ var patchRes = await GraphRequestAsync(
481
+ credential,
482
+ HttpMethod.Patch,
483
+ $"{SchemaConstants.GraphBaseUrl}/admin/people/profilePropertySettings/{id}",
484
+ patch
485
+ );
486
+
487
+ if (!patchRes.IsSuccessStatusCode)
488
+ {
489
+ var text = await patchRes.Content.ReadAsStringAsync();
490
+ throw new InvalidOperationException($"Failed to update profile property setting {id} (HTTP {(int)patchRes.StatusCode}): {text}");
491
+ }
492
+ }
493
+ }
494
+
495
+ private static List<string> DeduplicateUrls(List<string> values)
496
+ {
497
+ var result = new List<string>();
498
+ var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
499
+ foreach (var value in values)
500
+ {
501
+ if (string.IsNullOrWhiteSpace(value)) continue;
502
+ if (!seen.Add(value)) continue;
503
+ result.Add(value);
504
+ }
505
+ return result;
506
+ }
507
+
508
+ private async Task<bool> ProfileSourceExistsAsync(string connectionId)
509
+ {
510
+ var res = await GraphRequestAsync(
511
+ _credential!,
512
+ HttpMethod.Get,
513
+ $"{SchemaConstants.GraphBaseUrl}/admin/people/profileSources"
514
+ );
515
+
516
+ if (!res.IsSuccessStatusCode)
517
+ {
518
+ var text = await res.Content.ReadAsStringAsync();
519
+ throw new InvalidOperationException($"Failed to list profile sources (HTTP {(int)res.StatusCode}): {text}");
520
+ }
521
+
522
+ var json = await res.Content.ReadAsStringAsync();
523
+ using var doc = JsonDocument.Parse(json);
524
+ if (!doc.RootElement.TryGetProperty("value", out var values) || values.ValueKind != JsonValueKind.Array)
525
+ return false;
526
+
527
+ foreach (var entry in values.EnumerateArray())
528
+ {
529
+ if (entry.TryGetProperty("sourceId", out var idProp) && idProp.ValueKind == JsonValueKind.String)
530
+ {
531
+ var sourceId = idProp.GetString();
532
+ if (!string.IsNullOrWhiteSpace(sourceId) && sourceId == connectionId)
533
+ return true;
534
+ }
535
+ }
536
+
537
+ return false;
538
+ }
539
+ <% } -%>
540
+
541
+ private async Task WaitForSchemaReadyAsync()
542
+ {
543
+ EnsureGraph();
544
+ Console.WriteLine("info: waiting for schema provisioning...");
545
+ for (var attempt = 0; attempt < SchemaPollMaxAttempts; attempt++)
546
+ {
547
+ var schema = await _graph!.External.Connections[_connectionId].Schema.GetAsync();
548
+ var state = GetSchemaState(schema?.AdditionalData);
549
+ if (string.IsNullOrWhiteSpace(state)) return;
550
+
551
+ var normalized = state.Trim().ToLowerInvariant();
552
+ if (SchemaReadyStates.Contains(normalized)) return;
553
+ if (!SchemaPendingStates.Contains(normalized))
554
+ {
555
+ throw new InvalidOperationException($"Schema provisioning failed with status '{state}'.");
556
+ }
557
+ var progress = $"info: schema status '{state}' ({attempt + 1}/{SchemaPollMaxAttempts})";
558
+ Console.WriteLine(progress);
559
+ await Task.Delay(SchemaPollDelayMs);
560
+ }
561
+
562
+ throw new InvalidOperationException("Schema provisioning timed out.");
563
+ }
564
+
565
+ private static string? GetSchemaState(IDictionary<string, object>? data)
566
+ {
567
+ if (data is null) return null;
568
+ if (!data.TryGetValue("status", out var statusObj) || statusObj is null) return null;
569
+
570
+ if (statusObj is string statusString) return statusString;
571
+ if (statusObj is JsonElement json)
572
+ {
573
+ if (json.ValueKind == JsonValueKind.String) return json.GetString();
574
+ if (json.ValueKind == JsonValueKind.Object && json.TryGetProperty("state", out var stateProp))
575
+ {
576
+ return stateProp.ValueKind == JsonValueKind.String ? stateProp.GetString() : null;
577
+ }
578
+ }
579
+ if (statusObj is IDictionary<string, object> dict && dict.TryGetValue("state", out var stateObj))
580
+ {
581
+ return stateObj?.ToString();
582
+ }
583
+ return null;
584
+ }
585
+
586
+ private const int SchemaPollMaxAttempts = 12;
587
+ private const int SchemaPollDelayMs = 30000;
588
+ private static readonly HashSet<string> SchemaReadyStates = new(StringComparer.OrdinalIgnoreCase)
589
+ {
590
+ "completed",
591
+ "ready",
592
+ "succeeded",
593
+ "success",
594
+ };
595
+ private static readonly HashSet<string> SchemaPendingStates = new(StringComparer.OrdinalIgnoreCase)
596
+ {
597
+ "inprogress",
598
+ "pending",
599
+ "running",
600
+ "updating",
601
+ };
602
+ }
@@ -1,23 +1,32 @@
1
+ // Default CSV datasource implementation.
1
2
  using System.Globalization;
2
3
  using System.Runtime.CompilerServices;
3
4
 
4
5
  using CsvHelper;
5
6
 
6
- using <%= namespaceName %>.Schema;
7
+ using <%= schemaNamespace %>;
7
8
 
8
9
  namespace <%= namespaceName %>.Datasource;
9
10
 
10
- // CSV-based datasource (default). Replace with your own IItemSource as needed.
11
+ /// <summary>
12
+ /// CSV-based datasource (default). Replace with your own IItemSource as needed.
13
+ /// </summary>
11
14
  public sealed class CsvItemSource : IItemSource
12
15
  {
13
16
  private readonly string _csvPath;
14
17
 
18
+ /// <summary>
19
+ /// Create a CSV datasource reading from the provided file path.
20
+ /// </summary>
15
21
  public CsvItemSource(string csvPath)
16
22
  {
17
23
  _csvPath = csvPath;
18
24
  }
19
25
 
20
- public async IAsyncEnumerable<Item> GetItemsAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
26
+ /// <summary>
27
+ /// Stream items from the CSV file and map each row to the schema model.
28
+ /// </summary>
29
+ public async IAsyncEnumerable<<%= itemTypeName %>> GetItemsAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
21
30
  {
22
31
  // CsvHelper is sync; we still expose async enumeration for consistency.
23
32
  using var reader = new StreamReader(_csvPath);
@@ -1,12 +1,18 @@
1
+ // Datasource contract for producing items to ingest.
1
2
  using System.Collections.Generic;
2
3
 
3
- using <%= namespaceName %>.Schema;
4
+ using <%= schemaNamespace %>;
4
5
 
5
6
  namespace <%= namespaceName %>.Datasource;
6
7
 
7
- // Contract for any datasource that yields Items for ingestion.
8
- // Implement this interface to swap CSV for an API, database, or other system.
8
+ /// <summary>
9
+ /// Contract for any datasource that yields items for ingestion.
10
+ /// Implement this interface to swap CSV for an API, database, or other system.
11
+ /// </summary>
9
12
  public interface IItemSource
10
13
  {
11
- IAsyncEnumerable<Item> GetItemsAsync(CancellationToken cancellationToken = default);
14
+ /// <summary>
15
+ /// Stream items asynchronously for ingestion.
16
+ /// </summary>
17
+ IAsyncEnumerable<<%= itemTypeName %>> GetItemsAsync(CancellationToken cancellationToken = default);
12
18
  }
@@ -1,5 +1,9 @@
1
- namespace <%= namespaceName %>.Schema;
1
+ // Schema and connection constants derived from TypeSpec.
2
+ namespace <%= schemaNamespace %>;
2
3
 
4
+ /// <summary>
5
+ /// Constants used by the generated connector.
6
+ /// </summary>
3
7
  public static class SchemaConstants
4
8
  {
5
9
  public const string GraphApiVersion = <%= JSON.stringify(graphApiVersion) %>;