@wictorwilen/cocogen 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +149 -0
- package/RELEASING.md +36 -0
- package/THIRD_PARTY_NOTICES.md +11 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +288 -0
- package/dist/cli.js.map +1 -0
- package/dist/emit/emit.d.ts +3 -0
- package/dist/emit/emit.d.ts.map +1 -0
- package/dist/emit/emit.js +13 -0
- package/dist/emit/emit.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/init/init.d.ts +34 -0
- package/dist/init/init.d.ts.map +1 -0
- package/dist/init/init.js +886 -0
- package/dist/init/init.js.map +1 -0
- package/dist/init/template.d.ts +2 -0
- package/dist/init/template.d.ts.map +1 -0
- package/dist/init/template.js +19 -0
- package/dist/init/template.js.map +1 -0
- package/dist/init/templates/dotnet/.env.example.ejs +12 -0
- package/dist/init/templates/dotnet/.gitignore.ejs +6 -0
- package/dist/init/templates/dotnet/Datasource/CsvItemSource.cs.ejs +42 -0
- package/dist/init/templates/dotnet/Datasource/IItemSource.cs.ejs +12 -0
- package/dist/init/templates/dotnet/Generated/Constants.cs.ejs +17 -0
- package/dist/init/templates/dotnet/Generated/CsvParser.cs.ejs +119 -0
- package/dist/init/templates/dotnet/Generated/FromCsvRow.cs.ejs +14 -0
- package/dist/init/templates/dotnet/Generated/ItemPayload.cs.ejs +41 -0
- package/dist/init/templates/dotnet/Generated/Model.cs.ejs +28 -0
- package/dist/init/templates/dotnet/Generated/PersonEntityDefaults.cs.ejs +48 -0
- package/dist/init/templates/dotnet/Generated/PropertyTransforms.cs.ejs +22 -0
- package/dist/init/templates/dotnet/Generated/SchemaPayload.cs.ejs +18 -0
- package/dist/init/templates/dotnet/PersonEntityOverrides.cs.ejs +49 -0
- package/dist/init/templates/dotnet/Program.commandline.cs.ejs +426 -0
- package/dist/init/templates/dotnet/Program.cs.ejs +487 -0
- package/dist/init/templates/dotnet/README.md.ejs +56 -0
- package/dist/init/templates/dotnet/appsettings.json.ejs +21 -0
- package/dist/init/templates/dotnet/package.json.ejs +7 -0
- package/dist/init/templates/dotnet/project.csproj.ejs +29 -0
- package/dist/init/templates/dotnet/tspconfig.yaml.ejs +2 -0
- package/dist/init/templates/ts/.env.example.ejs +20 -0
- package/dist/init/templates/ts/README.md.ejs +54 -0
- package/dist/init/templates/ts/package.json.ejs +25 -0
- package/dist/init/templates/ts/src/cli.ts.ejs +299 -0
- package/dist/init/templates/ts/src/datasource/csvItemSource.ts.ejs +25 -0
- package/dist/init/templates/ts/src/datasource/itemSource.ts.ejs +8 -0
- package/dist/init/templates/ts/src/generated/constants.ts.ejs +10 -0
- package/dist/init/templates/ts/src/generated/csv.ts.ejs +44 -0
- package/dist/init/templates/ts/src/generated/fromCsvRow.ts.ejs +43 -0
- package/dist/init/templates/ts/src/generated/index.ts.ejs +5 -0
- package/dist/init/templates/ts/src/generated/itemPayload.ts.ejs +21 -0
- package/dist/init/templates/ts/src/generated/model.ts.ejs +16 -0
- package/dist/init/templates/ts/src/generated/personEntityDefaults.ts.ejs +33 -0
- package/dist/init/templates/ts/src/generated/propertyTransforms.ts.ejs +23 -0
- package/dist/init/templates/ts/src/generated/schemaPayload.ts.ejs +1 -0
- package/dist/init/templates/ts/src/index.ts.ejs +1 -0
- package/dist/init/templates/ts/src/personEntityOverrides.ts.ejs +36 -0
- package/dist/init/templates/ts/tsconfig.json.ejs +13 -0
- package/dist/init/templates/ts/tspconfig.yaml.ejs +2 -0
- package/dist/ir.d.ts +49 -0
- package/dist/ir.d.ts.map +1 -0
- package/dist/ir.js +2 -0
- package/dist/ir.js.map +1 -0
- package/dist/tsp/init-tsp.d.ts +14 -0
- package/dist/tsp/init-tsp.d.ts.map +1 -0
- package/dist/tsp/init-tsp.js +126 -0
- package/dist/tsp/init-tsp.js.map +1 -0
- package/dist/tsp/loader.d.ts +8 -0
- package/dist/tsp/loader.d.ts.map +1 -0
- package/dist/tsp/loader.js +264 -0
- package/dist/tsp/loader.js.map +1 -0
- package/dist/typespec/decorators.d.ts +14 -0
- package/dist/typespec/decorators.d.ts.map +1 -0
- package/dist/typespec/decorators.js +139 -0
- package/dist/typespec/decorators.js.map +1 -0
- package/dist/typespec/state.d.ts +37 -0
- package/dist/typespec/state.d.ts.map +1 -0
- package/dist/typespec/state.js +13 -0
- package/dist/typespec/state.js.map +1 -0
- package/dist/validate/validator.d.ts +9 -0
- package/dist/validate/validator.d.ts.map +1 -0
- package/dist/validate/validator.js +204 -0
- package/dist/validate/validator.js.map +1 -0
- package/package.json +66 -0
- package/typespec/main.tsp +117 -0
- package/typespec/tsp-index.js +6 -0
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
using Azure.Identity;
|
|
2
|
+
using Azure.Core;
|
|
3
|
+
using Microsoft.Graph;
|
|
4
|
+
using Microsoft.Graph.Models.ExternalConnectors;
|
|
5
|
+
using Microsoft.Graph.Models.ODataErrors;
|
|
6
|
+
using Microsoft.Kiota.Abstractions;
|
|
7
|
+
using Microsoft.Kiota.Abstractions.Serialization;
|
|
8
|
+
using System.Net.Http.Headers;
|
|
9
|
+
using System.Text;
|
|
10
|
+
using System.Text.Json;
|
|
11
|
+
|
|
12
|
+
using <%= namespaceName %>.Datasource;
|
|
13
|
+
using <%= namespaceName %>.Generated;
|
|
14
|
+
|
|
15
|
+
var command = args.Length > 0 ? args[0] : "";
|
|
16
|
+
|
|
17
|
+
static string RequiredEnv(string name)
|
|
18
|
+
{
|
|
19
|
+
var value = Environment.GetEnvironmentVariable(name);
|
|
20
|
+
if (string.IsNullOrWhiteSpace(value))
|
|
21
|
+
throw new InvalidOperationException($"Missing env var: {name}");
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
static GraphServiceClient CreateGraphClient()
|
|
26
|
+
{
|
|
27
|
+
var credential = CreateCredential();
|
|
28
|
+
|
|
29
|
+
var graph = new GraphServiceClient(credential, new[] { "https://graph.microsoft.com/.default" });
|
|
30
|
+
graph.RequestAdapter.BaseUrl = SchemaConstants.GraphBaseUrl;
|
|
31
|
+
return graph;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
static ClientSecretCredential CreateCredential()
|
|
35
|
+
{
|
|
36
|
+
var tenantId = RequiredEnv("TENANT_ID");
|
|
37
|
+
var clientId = RequiredEnv("CLIENT_ID");
|
|
38
|
+
var clientSecret = RequiredEnv("CLIENT_SECRET");
|
|
39
|
+
return new ClientSecretCredential(tenantId, clientId, clientSecret);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
static async Task<string> GetAccessTokenAsync(ClientSecretCredential credential)
|
|
43
|
+
{
|
|
44
|
+
var token = await credential.GetTokenAsync(
|
|
45
|
+
new TokenRequestContext(new[] { "https://graph.microsoft.com/.default" })
|
|
46
|
+
);
|
|
47
|
+
return token.Token;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
static async Task<HttpResponseMessage> GraphRequestAsync(ClientSecretCredential credential, HttpMethod method, string url, object? body = null)
|
|
51
|
+
{
|
|
52
|
+
using var http = new HttpClient();
|
|
53
|
+
var token = await GetAccessTokenAsync(credential);
|
|
54
|
+
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
|
55
|
+
|
|
56
|
+
var request = new HttpRequestMessage(method, url);
|
|
57
|
+
if (body is not null)
|
|
58
|
+
{
|
|
59
|
+
var json = JsonSerializer.Serialize(body);
|
|
60
|
+
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return await http.SendAsync(request);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
static bool IsStatus(ApiException ex, int statusCode)
|
|
67
|
+
{
|
|
68
|
+
return ex.ResponseStatusCode is int sc && sc == statusCode;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
static async Task EnsureConnectionAsync(GraphServiceClient graph, string connectionId)
|
|
72
|
+
{
|
|
73
|
+
var name = RequiredEnv("CONNECTION_NAME");
|
|
74
|
+
var description = RequiredEnv("CONNECTION_DESCRIPTION");
|
|
75
|
+
|
|
76
|
+
try
|
|
77
|
+
{
|
|
78
|
+
await graph.External.Connections[connectionId].GetAsync();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
using Azure.Core;
|
|
82
|
+
using Azure.Identity;
|
|
83
|
+
using Microsoft.Extensions.Configuration;
|
|
84
|
+
using Microsoft.Graph;
|
|
85
|
+
using Microsoft.Graph.Models.ExternalConnectors;
|
|
86
|
+
using Microsoft.Graph.Models.ODataErrors;
|
|
87
|
+
using Microsoft.Kiota.Abstractions;
|
|
88
|
+
using Microsoft.Kiota.Abstractions.Serialization;
|
|
89
|
+
using System.CommandLine;
|
|
90
|
+
using System.Net.Http.Headers;
|
|
91
|
+
using System.Text;
|
|
92
|
+
using System.Text.Json;
|
|
93
|
+
|
|
94
|
+
using <%= namespaceName %>.Datasource;
|
|
95
|
+
using <%= namespaceName %>.Schema;
|
|
96
|
+
|
|
97
|
+
var configuration = new ConfigurationBuilder()
|
|
98
|
+
.SetBasePath(Directory.GetCurrentDirectory())
|
|
99
|
+
.AddJsonFile("appsettings.json", optional: true)
|
|
100
|
+
.AddJsonFile("appsettings.Development.json", optional: true)
|
|
101
|
+
.AddEnvironmentVariables()
|
|
102
|
+
.Build();
|
|
103
|
+
|
|
104
|
+
string RequiredSetting(string key)
|
|
105
|
+
{
|
|
106
|
+
var value = configuration[key];
|
|
107
|
+
if (string.IsNullOrWhiteSpace(value))
|
|
108
|
+
throw new InvalidOperationException($"Missing configuration: {key}");
|
|
109
|
+
return value;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
string ConnectionId() => RequiredSetting("Connection:Id");
|
|
113
|
+
string ConnectionName() => RequiredSetting("Connection:Name");
|
|
114
|
+
string ConnectionDescription() => RequiredSetting("Connection:Description");
|
|
115
|
+
|
|
116
|
+
ClientSecretCredential CreateCredential()
|
|
117
|
+
{
|
|
118
|
+
var tenantId = RequiredSetting("AzureAd:TenantId");
|
|
119
|
+
var clientId = RequiredSetting("AzureAd:ClientId");
|
|
120
|
+
var clientSecret = RequiredSetting("AzureAd:ClientSecret");
|
|
121
|
+
return new ClientSecretCredential(tenantId, clientId, clientSecret);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
GraphServiceClient CreateGraphClient()
|
|
125
|
+
{
|
|
126
|
+
var credential = CreateCredential();
|
|
127
|
+
var graph = new GraphServiceClient(credential, new[] { "https://graph.microsoft.com/.default" });
|
|
128
|
+
graph.RequestAdapter.BaseUrl = SchemaConstants.GraphBaseUrl;
|
|
129
|
+
return graph;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async Task<string> GetAccessTokenAsync(ClientSecretCredential credential)
|
|
133
|
+
{
|
|
134
|
+
var token = await credential.GetTokenAsync(
|
|
135
|
+
new TokenRequestContext(new[] { "https://graph.microsoft.com/.default" })
|
|
136
|
+
);
|
|
137
|
+
return token.Token;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async Task<HttpResponseMessage> GraphRequestAsync(ClientSecretCredential credential, HttpMethod method, string url, object? body = null)
|
|
141
|
+
{
|
|
142
|
+
using var http = new HttpClient();
|
|
143
|
+
var token = await GetAccessTokenAsync(credential);
|
|
144
|
+
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
|
145
|
+
|
|
146
|
+
var request = new HttpRequestMessage(method, url);
|
|
147
|
+
if (body is not null)
|
|
148
|
+
{
|
|
149
|
+
var json = JsonSerializer.Serialize(body);
|
|
150
|
+
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return await http.SendAsync(request);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
bool IsStatus(ApiException ex, int statusCode)
|
|
157
|
+
{
|
|
158
|
+
return ex.ResponseStatusCode is int sc && sc == statusCode;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async Task EnsureConnectionAsync(GraphServiceClient graph, string connectionId)
|
|
162
|
+
{
|
|
163
|
+
try
|
|
164
|
+
{
|
|
165
|
+
await graph.External.Connections[connectionId].GetAsync();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
catch (ApiException ex) when (IsStatus(ex, 404))
|
|
169
|
+
{
|
|
170
|
+
// Create below.
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
var connection = new ExternalConnection
|
|
174
|
+
{
|
|
175
|
+
Id = connectionId,
|
|
176
|
+
Name = ConnectionName(),
|
|
177
|
+
Description = ConnectionDescription(),
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
if (!string.IsNullOrWhiteSpace(SchemaConstants.ContentCategory))
|
|
181
|
+
{
|
|
182
|
+
// contentCategory is currently only exposed on the /beta endpoint.
|
|
183
|
+
connection.AdditionalData ??= new Dictionary<string, object>();
|
|
184
|
+
connection.AdditionalData["contentCategory"] = SchemaConstants.ContentCategory!;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
await graph.External.Connections.PostAsync(connection);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async Task PatchSchemaAsync(GraphServiceClient graph, string connectionId)
|
|
191
|
+
{
|
|
192
|
+
var schema = SchemaPayload.BuildSchema();
|
|
193
|
+
await graph.External.Connections[connectionId].Schema.PatchAsync(schema);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async Task ProvisionAsync()
|
|
197
|
+
{
|
|
198
|
+
var graph = CreateGraphClient();
|
|
199
|
+
var connectionId = ConnectionId();
|
|
200
|
+
|
|
201
|
+
await EnsureConnectionAsync(graph, connectionId);
|
|
202
|
+
await PatchSchemaAsync(graph, connectionId);
|
|
203
|
+
<% if (isPeopleConnector) { -%>
|
|
204
|
+
await RegisterProfileSourceAsync(connectionId);
|
|
205
|
+
<% } -%>
|
|
206
|
+
|
|
207
|
+
Console.WriteLine("ok: provisioned");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async Task PutItemAsync(GraphServiceClient graph, string connectionId, Item item)
|
|
211
|
+
{
|
|
212
|
+
var itemId = ItemPayload.GetItemId(item);
|
|
213
|
+
|
|
214
|
+
var externalItem = ItemPayload.ToExternalItem(item);
|
|
215
|
+
|
|
216
|
+
// NOTE: The external connectors surface may not expose a typed PutAsync on all SDK versions.
|
|
217
|
+
// We still use the Graph SDK's request adapter and models for a strongly-typed payload.
|
|
218
|
+
var requestInfo = new RequestInformation
|
|
219
|
+
{
|
|
220
|
+
HttpMethod = Method.PUT,
|
|
221
|
+
UrlTemplate = "{+baseurl}/external/connections/{connectionId}/items/{itemId}",
|
|
222
|
+
PathParameters = new Dictionary<string, object>
|
|
223
|
+
{
|
|
224
|
+
{ "baseurl", graph.RequestAdapter.BaseUrl },
|
|
225
|
+
{ "connectionId", connectionId },
|
|
226
|
+
{ "itemId", itemId },
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
requestInfo.Headers.Add("Accept", "application/json");
|
|
231
|
+
requestInfo.SetContentFromParsable(graph.RequestAdapter, "application/json", externalItem);
|
|
232
|
+
|
|
233
|
+
var errorMapping = new Dictionary<string, ParsableFactory<IParsable>>
|
|
234
|
+
{
|
|
235
|
+
{ "4XX", ODataError.CreateFromDiscriminatorValue },
|
|
236
|
+
{ "5XX", ODataError.CreateFromDiscriminatorValue },
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
await graph.RequestAdapter.SendAsync<ExternalItem>(
|
|
240
|
+
requestInfo,
|
|
241
|
+
ExternalItem.CreateFromDiscriminatorValue,
|
|
242
|
+
errorMapping
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async Task IngestAsync(string? csvPath)
|
|
247
|
+
{
|
|
248
|
+
var graph = CreateGraphClient();
|
|
249
|
+
var connectionId = ConnectionId();
|
|
250
|
+
|
|
251
|
+
var configuredCsv = configuration["Csv:Path"] ?? "data.csv";
|
|
252
|
+
var path = string.IsNullOrWhiteSpace(csvPath) ? configuredCsv : csvPath;
|
|
253
|
+
|
|
254
|
+
IItemSource source = new CsvItemSource(path);
|
|
255
|
+
|
|
256
|
+
var count = 0;
|
|
257
|
+
await foreach (var item in source.GetItemsAsync())
|
|
258
|
+
{
|
|
259
|
+
await PutItemAsync(graph, connectionId, item);
|
|
260
|
+
count++;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
Console.WriteLine($"ok: ingested {count} item(s)");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
<% if (isPeopleConnector) { -%>
|
|
267
|
+
async Task RegisterProfileSourceAsync(string connectionId)
|
|
268
|
+
{
|
|
269
|
+
var credential = CreateCredential();
|
|
270
|
+
var webUrl = RequiredSetting("ProfileSource:WebUrl");
|
|
271
|
+
var displayName = configuration["ProfileSource:DisplayName"] ?? ConnectionName();
|
|
272
|
+
var kind = configuration["ProfileSource:Kind"];
|
|
273
|
+
|
|
274
|
+
var payload = new Dictionary<string, object?>
|
|
275
|
+
{
|
|
276
|
+
["sourceId"] = connectionId,
|
|
277
|
+
["displayName"] = displayName,
|
|
278
|
+
["webUrl"] = webUrl,
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
if (!string.IsNullOrWhiteSpace(kind))
|
|
282
|
+
payload["kind"] = kind;
|
|
283
|
+
|
|
284
|
+
var create = await GraphRequestAsync(
|
|
285
|
+
credential,
|
|
286
|
+
HttpMethod.Post,
|
|
287
|
+
$"{SchemaConstants.GraphBaseUrl}/admin/people/profileSources",
|
|
288
|
+
payload
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
if (!create.IsSuccessStatusCode && create.StatusCode != System.Net.HttpStatusCode.Conflict)
|
|
292
|
+
{
|
|
293
|
+
var text = await create.Content.ReadAsStringAsync();
|
|
294
|
+
throw new InvalidOperationException($"Failed to register profile source (HTTP {(int)create.StatusCode}): {text}");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
var sourceUrl = $"{SchemaConstants.GraphBaseUrl}/admin/people/profileSources(sourceId='{connectionId}')";
|
|
298
|
+
await UpdateProfileSourcePrecedenceAsync(credential, sourceUrl, prepend: true);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async Task UnregisterProfileSourceAsync(string connectionId)
|
|
302
|
+
{
|
|
303
|
+
var credential = CreateCredential();
|
|
304
|
+
var sourceUrl = $"{SchemaConstants.GraphBaseUrl}/admin/people/profileSources(sourceId='{connectionId}')";
|
|
305
|
+
|
|
306
|
+
await UpdateProfileSourcePrecedenceAsync(credential, sourceUrl, prepend: false);
|
|
307
|
+
|
|
308
|
+
var res = await GraphRequestAsync(
|
|
309
|
+
credential,
|
|
310
|
+
HttpMethod.Delete,
|
|
311
|
+
$"{SchemaConstants.GraphBaseUrl}/admin/people/profileSources(sourceId='{connectionId}')"
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
if (!res.IsSuccessStatusCode && res.StatusCode != System.Net.HttpStatusCode.NotFound)
|
|
315
|
+
{
|
|
316
|
+
var text = await res.Content.ReadAsStringAsync();
|
|
317
|
+
throw new InvalidOperationException($"Failed to delete profile source (HTTP {(int)res.StatusCode}): {text}");
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async Task UpdateProfileSourcePrecedenceAsync(ClientSecretCredential credential, string sourceUrl, bool prepend)
|
|
322
|
+
{
|
|
323
|
+
var res = await GraphRequestAsync(
|
|
324
|
+
credential,
|
|
325
|
+
HttpMethod.Get,
|
|
326
|
+
$"{SchemaConstants.GraphBaseUrl}/admin/people/profilePropertySettings"
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
if (!res.IsSuccessStatusCode)
|
|
330
|
+
{
|
|
331
|
+
var text = await res.Content.ReadAsStringAsync();
|
|
332
|
+
throw new InvalidOperationException($"Failed to list profile property settings (HTTP {(int)res.StatusCode}): {text}");
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
var json = await res.Content.ReadAsStringAsync();
|
|
336
|
+
using var doc = JsonDocument.Parse(json);
|
|
337
|
+
if (!doc.RootElement.TryGetProperty("value", out var values) || values.ValueKind != JsonValueKind.Array)
|
|
338
|
+
return;
|
|
339
|
+
|
|
340
|
+
foreach (var entry in values.EnumerateArray())
|
|
341
|
+
{
|
|
342
|
+
if (!entry.TryGetProperty("id", out var idProp) || idProp.ValueKind != JsonValueKind.String)
|
|
343
|
+
continue;
|
|
344
|
+
|
|
345
|
+
var id = idProp.GetString() ?? string.Empty;
|
|
346
|
+
if (string.IsNullOrWhiteSpace(id)) continue;
|
|
347
|
+
|
|
348
|
+
List<string> existing = new();
|
|
349
|
+
if (entry.TryGetProperty("prioritizedSourceUrls", out var urls) && urls.ValueKind == JsonValueKind.Array)
|
|
350
|
+
{
|
|
351
|
+
foreach (var url in urls.EnumerateArray())
|
|
352
|
+
{
|
|
353
|
+
if (url.ValueKind == JsonValueKind.String)
|
|
354
|
+
existing.Add(url.GetString() ?? "");
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
existing = existing.Where((u) => !string.IsNullOrWhiteSpace(u) && u != sourceUrl).ToList();
|
|
359
|
+
var updated = prepend ? new[] { sourceUrl }.Concat(existing).ToList() : existing;
|
|
360
|
+
|
|
361
|
+
var patch = new Dictionary<string, object?>
|
|
362
|
+
{
|
|
363
|
+
["@odata.type"] = "#microsoft.graph.profilePropertySetting",
|
|
364
|
+
["prioritizedSourceUrls"] = updated,
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
var patchRes = await GraphRequestAsync(
|
|
368
|
+
credential,
|
|
369
|
+
HttpMethod.Patch,
|
|
370
|
+
$"{SchemaConstants.GraphBaseUrl}/admin/people/profilePropertySettings/{id}",
|
|
371
|
+
patch
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
if (!patchRes.IsSuccessStatusCode)
|
|
375
|
+
{
|
|
376
|
+
var text = await patchRes.Content.ReadAsStringAsync();
|
|
377
|
+
throw new InvalidOperationException($"Failed to update profile property setting {id} (HTTP {(int)patchRes.StatusCode}): {text}");
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
<% } -%>
|
|
382
|
+
|
|
383
|
+
async Task DeleteConnectionAsync()
|
|
384
|
+
{
|
|
385
|
+
var graph = CreateGraphClient();
|
|
386
|
+
var connectionId = ConnectionId();
|
|
387
|
+
|
|
388
|
+
<% if (isPeopleConnector) { -%>
|
|
389
|
+
await UnregisterProfileSourceAsync(connectionId);
|
|
390
|
+
<% } -%>
|
|
391
|
+
|
|
392
|
+
try
|
|
393
|
+
{
|
|
394
|
+
await graph.External.Connections[connectionId].DeleteAsync();
|
|
395
|
+
}
|
|
396
|
+
catch (ApiException ex) when (IsStatus(ex, 404))
|
|
397
|
+
{
|
|
398
|
+
// Already deleted.
|
|
399
|
+
}
|
|
400
|
+
Console.WriteLine("ok: deleted");
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
var csvOption = new Option<string>("--csv", description: "CSV path");
|
|
404
|
+
|
|
405
|
+
var root = new RootCommand("Connector CLI generated by gcgen");
|
|
406
|
+
|
|
407
|
+
var provisionCommand = new Command("provision", "Create or update the connection and schema");
|
|
408
|
+
provisionCommand.SetHandler(async () => await ProvisionAsync());
|
|
409
|
+
|
|
410
|
+
var ingestCommand = new Command("ingest", "Ingest items from CSV");
|
|
411
|
+
ingestCommand.AddOption(csvOption);
|
|
412
|
+
ingestCommand.SetHandler(async (string? csv) => await IngestAsync(csv), csvOption);
|
|
413
|
+
|
|
414
|
+
var deleteCommand = new Command("delete", "Delete the connection");
|
|
415
|
+
deleteCommand.SetHandler(async () => await DeleteConnectionAsync());
|
|
416
|
+
|
|
417
|
+
root.AddCommand(provisionCommand);
|
|
418
|
+
root.AddCommand(ingestCommand);
|
|
419
|
+
root.AddCommand(deleteCommand);
|
|
420
|
+
|
|
421
|
+
<% if (isPeopleConnector) { -%>
|
|
422
|
+
var registerCommand = new Command("register-profile-source", "Register the connection as a profile source");
|
|
423
|
+
registerCommand.SetHandler(async () => await RegisterProfileSourceAsync(ConnectionId()));
|
|
424
|
+
root.AddCommand(registerCommand);
|
|
425
|
+
<% } -%>
|
|
426
|
+
|
|
427
|
+
return await root.InvokeAsync(args);
|
|
428
|
+
{
|
|
429
|
+
var graph = CreateGraphClient();
|
|
430
|
+
var connectionId = RequiredEnv("CONNECTION_ID");
|
|
431
|
+
|
|
432
|
+
var csvArg = GetArgValue("--csv", allArgs);
|
|
433
|
+
var csvPath = !string.IsNullOrWhiteSpace(csvArg)
|
|
434
|
+
? csvArg
|
|
435
|
+
: (Environment.GetEnvironmentVariable("CSV_PATH") ?? "data.csv");
|
|
436
|
+
|
|
437
|
+
IItemSource source = new CsvItemSource(csvPath);
|
|
438
|
+
|
|
439
|
+
var count = 0;
|
|
440
|
+
await foreach (var item in source.GetItemsAsync())
|
|
441
|
+
{
|
|
442
|
+
await PutItemAsync(graph, connectionId, item);
|
|
443
|
+
count++;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
Console.WriteLine($"ok: ingested {count} item(s)");
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
try
|
|
450
|
+
{
|
|
451
|
+
if (command == "provision")
|
|
452
|
+
{
|
|
453
|
+
await ProvisionAsync();
|
|
454
|
+
}
|
|
455
|
+
else if (command == "ingest")
|
|
456
|
+
{
|
|
457
|
+
await IngestAsync(args);
|
|
458
|
+
}
|
|
459
|
+
else if (command == "register-profile-source")
|
|
460
|
+
{
|
|
461
|
+
await RegisterProfileSourceAsync(RequiredEnv("CONNECTION_ID"));
|
|
462
|
+
}
|
|
463
|
+
else if (command == "delete")
|
|
464
|
+
{
|
|
465
|
+
await DeleteConnectionAsync();
|
|
466
|
+
}
|
|
467
|
+
else
|
|
468
|
+
{
|
|
469
|
+
Console.WriteLine("Usage:");
|
|
470
|
+
Console.WriteLine(" dotnet run -- provision");
|
|
471
|
+
Console.WriteLine(" dotnet run -- ingest --csv path/to.csv");
|
|
472
|
+
Console.WriteLine(" dotnet run -- register-profile-source");
|
|
473
|
+
Console.WriteLine(" dotnet run -- delete");
|
|
474
|
+
Environment.ExitCode = 1;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
catch (ApiException ex)
|
|
478
|
+
{
|
|
479
|
+
Console.Error.WriteLine("error: Graph request failed");
|
|
480
|
+
Console.Error.WriteLine(ex.Message);
|
|
481
|
+
Environment.ExitCode = 1;
|
|
482
|
+
}
|
|
483
|
+
catch (Exception ex)
|
|
484
|
+
{
|
|
485
|
+
Console.Error.WriteLine("error: " + ex.Message);
|
|
486
|
+
Environment.ExitCode = 1;
|
|
487
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
Generated by cocogen.
|
|
2
|
+
|
|
3
|
+
## Quickstart
|
|
4
|
+
1) Edit `appsettings.json` with your tenant/app/connection details.
|
|
5
|
+
2) `dotnet build`
|
|
6
|
+
3) `dotnet run -- provision`
|
|
7
|
+
4) `dotnet run -- ingest --csv ./data.csv`
|
|
8
|
+
5) (Optional) `dotnet run -- delete`
|
|
9
|
+
|
|
10
|
+
<% if (isPeopleConnector) { -%>
|
|
11
|
+
Note: this is a People connector (preview). It uses Graph beta endpoints and registers the connection as a profile source during `provision`.
|
|
12
|
+
<% } -%>
|
|
13
|
+
|
|
14
|
+
## Graph API version note
|
|
15
|
+
If your schema sets `@coco.connection({ contentCategory: "..." })`, provisioning must use Microsoft Graph **/beta** because `externalConnection.contentCategory` is currently exposed on the beta endpoint.
|
|
16
|
+
|
|
17
|
+
## Multiple connections
|
|
18
|
+
This generated CLI currently targets a single connection ID from configuration. Multi-connection support is planned for a future version.
|
|
19
|
+
|
|
20
|
+
## Requirements
|
|
21
|
+
- .NET 10 SDK
|
|
22
|
+
- Microsoft Entra app registration with application permissions:
|
|
23
|
+
- `ExternalConnection.ReadWrite.OwnedBy`
|
|
24
|
+
- `ExternalItem.ReadWrite.OwnedBy`
|
|
25
|
+
<% if (isPeopleConnector) { -%>
|
|
26
|
+
- `PeopleSettings.ReadWrite.All` (required for profile source registration)
|
|
27
|
+
<% } -%>
|
|
28
|
+
|
|
29
|
+
## TypeSpec editor support
|
|
30
|
+
This project includes `tspconfig.yaml` and a `package.json` with `@wictorwilen/cocogen` as a dev dependency so VS Code can resolve `using coco;`.
|
|
31
|
+
Run `npm install` in this folder to fetch the TypeSpec library.
|
|
32
|
+
|
|
33
|
+
## Customizing the project
|
|
34
|
+
- **Schema and fields**: edit `schema.tsp` (copied into this folder) and run `cocogen update --out .` to regenerate `Schema/`.
|
|
35
|
+
- **Ingestion**: replace the datasource in `Datasource/` (CSV is the default) and wire it in `Program.cs`.
|
|
36
|
+
- **People connectors**: customize entity payloads in `Schema/PersonEntityOverrides.cs` (kept on updates).
|
|
37
|
+
- **Property transforms**: edit `Schema/PropertyTransforms.cs` to customize per-field parsing/mapping.
|
|
38
|
+
- **Connection defaults**: `@coco.connection` can set `connectionId` and `connectionDescription` defaults for `appsettings.json`.
|
|
39
|
+
- **Profile source defaults**: `@coco.profileSource` sets defaults for `ProfileSource` settings (people connectors only).
|
|
40
|
+
- **Access control**: edit `Schema/ItemPayload.cs` to change ACL behavior.
|
|
41
|
+
|
|
42
|
+
## Ingest debugging flags
|
|
43
|
+
Use `dotnet run -- ingest` with:
|
|
44
|
+
- `--dry-run` (build payloads without sending)
|
|
45
|
+
- `--limit <n>` (ingest only N items)
|
|
46
|
+
- `--verbose` (print the exact payload sent to Graph)
|
|
47
|
+
|
|
48
|
+
Note: `--dry-run` does not require Azure AD or connection settings.
|
|
49
|
+
|
|
50
|
+
## Switching from CSV to another datasource
|
|
51
|
+
1) Implement `IItemSource` in `Datasource/`.
|
|
52
|
+
2) If your source yields raw records, map them to `Item` using `FromCsvRow`-style logic.
|
|
53
|
+
3) Update `Program.cs` to instantiate your new source instead of `CsvItemSource`.
|
|
54
|
+
|
|
55
|
+
Tip: keep the `IAsyncEnumerable<Item>` pattern for large datasets.
|
|
56
|
+
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"AzureAd": {
|
|
3
|
+
"TenantId": "",
|
|
4
|
+
"ClientId": "",
|
|
5
|
+
"ClientSecret": ""
|
|
6
|
+
},
|
|
7
|
+
"Connection": {
|
|
8
|
+
"Id": "<%= connectionId ?? "my-connection-id" %>",
|
|
9
|
+
"Name": "<%= itemTypeName %>",
|
|
10
|
+
"Description": "<%= connectionDescription ?? `${itemTypeName} connector generated by cocogen` %>"
|
|
11
|
+
},
|
|
12
|
+
"Csv": {
|
|
13
|
+
"Path": "data.csv"
|
|
14
|
+
}<% if (isPeopleConnector) { -%>,
|
|
15
|
+
"ProfileSource": {
|
|
16
|
+
"WebUrl": "<%= profileSourceWebUrl ?? "https://example.com/people-data" %>",
|
|
17
|
+
"DisplayName": "<%= profileSourceDisplayName ?? itemTypeName %>",
|
|
18
|
+
"Priority": "<%= profileSourcePriority ?? "first" %>"
|
|
19
|
+
}
|
|
20
|
+
<% } -%>
|
|
21
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<Project Sdk="Microsoft.NET.Sdk">
|
|
2
|
+
|
|
3
|
+
<PropertyGroup>
|
|
4
|
+
<OutputType>Exe</OutputType>
|
|
5
|
+
<TargetFramework>net10.0</TargetFramework>
|
|
6
|
+
<ImplicitUsings>enable</ImplicitUsings>
|
|
7
|
+
<Nullable>enable</Nullable>
|
|
8
|
+
<LangVersion>latest</LangVersion>
|
|
9
|
+
</PropertyGroup>
|
|
10
|
+
|
|
11
|
+
<ItemGroup>
|
|
12
|
+
<PackageReference Include="Azure.Identity" Version="1.17.1" />
|
|
13
|
+
<% if (graphApiVersion === "beta") { -%>
|
|
14
|
+
<PackageReference Include="Microsoft.Graph.Beta" Version="5.129.0-preview" />
|
|
15
|
+
<% } else { -%>
|
|
16
|
+
<PackageReference Include="Microsoft.Graph" Version="5.100.0" />
|
|
17
|
+
<% } -%>
|
|
18
|
+
<PackageReference Include="CsvHelper" Version="33.1.0" />
|
|
19
|
+
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
|
|
20
|
+
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.2" />
|
|
21
|
+
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.2" />
|
|
22
|
+
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.2" />
|
|
23
|
+
</ItemGroup>
|
|
24
|
+
|
|
25
|
+
<ItemGroup>
|
|
26
|
+
<None Include="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
|
|
27
|
+
</ItemGroup>
|
|
28
|
+
|
|
29
|
+
</Project>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Azure AD app credentials
|
|
2
|
+
TENANT_ID=
|
|
3
|
+
CLIENT_ID=
|
|
4
|
+
CLIENT_SECRET=
|
|
5
|
+
|
|
6
|
+
# Graph external connection
|
|
7
|
+
CONNECTION_ID=<%= connectionId ?? "my-connection-id" %>
|
|
8
|
+
CONNECTION_NAME=<%= itemTypeName %>
|
|
9
|
+
CONNECTION_DESCRIPTION=<%= connectionDescription ?? `${itemTypeName} connector generated by cocogen` %>
|
|
10
|
+
|
|
11
|
+
<% if (isPeopleConnector) { -%>
|
|
12
|
+
# People connector profile source (required for contentCategory=people)
|
|
13
|
+
PROFILE_SOURCE_WEB_URL=<%= profileSourceWebUrl ?? "https://example.com/people-data" %>
|
|
14
|
+
# Optional overrides
|
|
15
|
+
# PROFILE_SOURCE_DISPLAY_NAME=<%= profileSourceDisplayName ?? itemTypeName %>
|
|
16
|
+
# PROFILE_SOURCE_PRIORITY=<%= profileSourcePriority ?? "first" %>
|
|
17
|
+
<% } -%>
|
|
18
|
+
|
|
19
|
+
# CSV ingestion
|
|
20
|
+
CSV_PATH=data.csv
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
Generated by cocogen.
|
|
2
|
+
|
|
3
|
+
## Quickstart
|
|
4
|
+
1) Copy `.env.example` to `.env` and fill in values.
|
|
5
|
+
2) `npm install`
|
|
6
|
+
3) `npm run provision`
|
|
7
|
+
4) `npm run ingest`
|
|
8
|
+
5) (Optional) `npm run delete`
|
|
9
|
+
|
|
10
|
+
<% if (isPeopleConnector) { -%>
|
|
11
|
+
Note: this is a People connector (preview). It uses Graph beta endpoints and registers the connection as a profile source during `provision`.
|
|
12
|
+
<% } -%>
|
|
13
|
+
|
|
14
|
+
## Graph API version note
|
|
15
|
+
If your schema sets `@coco.connection({ contentCategory: "..." })`, provisioning must use Microsoft Graph **/beta** because `externalConnection.contentCategory` is currently exposed on the beta endpoint.
|
|
16
|
+
|
|
17
|
+
## Requirements
|
|
18
|
+
- Microsoft Entra app registration with application permissions:
|
|
19
|
+
- `ExternalConnection.ReadWrite.OwnedBy`
|
|
20
|
+
- `ExternalItem.ReadWrite.OwnedBy`
|
|
21
|
+
<% if (isPeopleConnector) { -%>
|
|
22
|
+
- `PeopleSettings.ReadWrite.All` (required for profile source registration)
|
|
23
|
+
<% } -%>
|
|
24
|
+
|
|
25
|
+
## TypeSpec editor support
|
|
26
|
+
This project includes `tspconfig.yaml` and a devDependency on `@wictorwilen/cocogen` so VS Code can resolve `using coco;`.
|
|
27
|
+
Run `npm install` to fetch the TypeSpec library.
|
|
28
|
+
|
|
29
|
+
## Customizing the project
|
|
30
|
+
- **Schema and fields**: edit `schema.tsp` (copied into this folder) and run `cocogen update --out .` to regenerate `src/schema`.
|
|
31
|
+
- **Ingestion**: replace the datasource in `src/datasource` (CSV is the default) and wire it in `src/cli.ts`.
|
|
32
|
+
- **People connectors**: customize entity payloads in `src/schema/personEntityOverrides.ts` (kept on updates).
|
|
33
|
+
- **Property transforms**: edit `src/schema/propertyTransforms.ts` to customize per-field parsing/mapping.
|
|
34
|
+
- **Connection defaults**: `@coco.connection` can set `connectionId` and `connectionDescription` defaults for `.env.example`.
|
|
35
|
+
- **Profile source defaults**: `@coco.profileSource` sets defaults for `PROFILE_SOURCE_*` (people connectors only).
|
|
36
|
+
- **Access control**: edit `src/schema/itemPayload.ts` to change ACL behavior.
|
|
37
|
+
|
|
38
|
+
## Ingest debugging flags
|
|
39
|
+
Use `npm run ingest --` with:
|
|
40
|
+
- `--dry-run` (build payloads without sending)
|
|
41
|
+
- `--limit <n>` (ingest only N items)
|
|
42
|
+
- `--verbose` (print the exact payload sent to Graph)
|
|
43
|
+
|
|
44
|
+
Note: `--dry-run` does not require CONNECTION_ID, but you still need it for real ingestion.
|
|
45
|
+
|
|
46
|
+
## Switching from CSV to another datasource
|
|
47
|
+
1) Implement `ItemSource` in `src/datasource`.
|
|
48
|
+
2) If your source yields raw records, map them to `Item` using `fromCsvRow`-style logic.
|
|
49
|
+
3) Update `src/cli.ts` to instantiate your new source instead of `CsvItemSource`.
|
|
50
|
+
|
|
51
|
+
Tip: keep the streaming `AsyncIterable<Item>` pattern for large datasets.
|
|
52
|
+
|
|
53
|
+
## Multiple connections
|
|
54
|
+
This generated CLI currently targets a single connection ID from environment variables. Multi-connection support is planned for a future version.
|