@bcgov/plugin-catalog-backend-module-bc-data-catalogue 0.2.6-main.59c9210c → 0.2.6-main.91ee0be1

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.
@@ -2,21 +2,50 @@
2
2
 
3
3
  var pluginCatalogCommonBcDataCatalogue = require('@bcgov/plugin-catalog-common-bc-data-catalogue');
4
4
  var BcDataCatalogueNaming = require('./BcDataCatalogueNaming.cjs.js');
5
+ var ApiEntityBuilder = require('./builders/ApiEntityBuilder.cjs.js');
6
+ var DatasetEntityBuilder = require('./builders/DatasetEntityBuilder.cjs.js');
7
+ var GroupEntityBuilder = require('./builders/GroupEntityBuilder.cjs.js');
8
+ var OpenApiEntityBuilder = require('./builders/OpenApiEntityBuilder.cjs.js');
9
+ var SystemEntityBuilder = require('./builders/SystemEntityBuilder.cjs.js');
10
+ var UserEntityBuilder = require('./builders/UserEntityBuilder.cjs.js');
5
11
 
6
- const MANAGED_BY_LOCATION = "url:https://catalogue.data.gov.bc.ca/api/3/action/package_search";
7
- const GAP = "GAP";
8
12
  class BcDataCatalogueEntityFactory {
9
13
  reader;
10
14
  logger;
11
15
  allowedHosts;
12
16
  naming;
13
17
  schemaUtils;
18
+ apiEntityBuilder;
19
+ datasetEntityBuilder;
20
+ groupEntityBuilder;
21
+ openApiEntityBuilder;
22
+ systemEntityBuilder;
23
+ userEntityBuilder;
14
24
  constructor(options) {
15
25
  this.reader = options.reader;
16
26
  this.logger = options.logger;
17
27
  this.allowedHosts = options.allowedHosts;
18
28
  this.naming = new BcDataCatalogueNaming.BcDataCatalogueNaming();
19
29
  this.schemaUtils = new pluginCatalogCommonBcDataCatalogue.BcDataCatalogueSchemaUtils();
30
+ this.apiEntityBuilder = new ApiEntityBuilder.ApiEntityBuilder({
31
+ naming: this.naming
32
+ });
33
+ this.datasetEntityBuilder = new DatasetEntityBuilder.DatasetEntityBuilder({
34
+ naming: this.naming,
35
+ schemaUtils: this.schemaUtils
36
+ });
37
+ this.groupEntityBuilder = new GroupEntityBuilder.GroupEntityBuilder({
38
+ naming: this.naming
39
+ });
40
+ this.openApiEntityBuilder = new OpenApiEntityBuilder.OpenApiEntityBuilder({
41
+ naming: this.naming
42
+ });
43
+ this.systemEntityBuilder = new SystemEntityBuilder.SystemEntityBuilder({
44
+ naming: this.naming
45
+ });
46
+ this.userEntityBuilder = new UserEntityBuilder.UserEntityBuilder({
47
+ naming: this.naming
48
+ });
20
49
  }
21
50
  async createEntities(allPackages) {
22
51
  this.logger.info("[BCDC Entity Factory] <createEntities");
@@ -25,48 +54,35 @@ class BcDataCatalogueEntityFactory {
25
54
  const bcGovGroupId = this.naming.getGroupId("gov.bc.ca");
26
55
  allGroups.set(
27
56
  bcGovGroupId,
28
- this.createGroupEntity("gov.bc.ca", "Governent of British Columbia", void 0)
57
+ this.groupEntityBuilder.build({
58
+ hostName: "gov.bc.ca",
59
+ displayName: "Governent of British Columbia"
60
+ })
29
61
  );
30
62
  const allOrganizations = /* @__PURE__ */ new Map();
31
63
  allPackages.forEach((pkg) => {
32
64
  allOrganizations.set(pkg.organization.id, pkg.organization);
33
65
  });
34
66
  const allSystems = /* @__PURE__ */ new Map();
35
- this.logger.info(`[BCDC Entity Factory] Organizations ${allOrganizations.size}`);
67
+ this.logger.info(
68
+ `[BCDC Entity Factory] Organizations ${allOrganizations.size}`
69
+ );
36
70
  allOrganizations.forEach((organization) => {
37
71
  const organizationGroupId = this.naming.getGroupId(organization.name);
38
72
  if (!allGroups.has(organizationGroupId)) {
39
73
  allGroups.set(
40
74
  organizationGroupId,
41
- this.createGroupEntity(
42
- organization.name,
43
- organization.title,
44
- bcGovGroupId
45
- )
75
+ this.groupEntityBuilder.build({
76
+ hostName: organization.name,
77
+ displayName: organization.title,
78
+ parentGroup: bcGovGroupId
79
+ })
46
80
  );
47
81
  }
48
- const systemEntity = {
49
- apiVersion: "backstage.io/v1alpha1",
50
- kind: "System",
51
- spec: {
52
- owner: organizationGroupId,
53
- type: "government"
54
- },
55
- metadata: {
56
- name: this.naming.toSafeName(organization.name),
57
- title: organization.title,
58
- description: organization.description,
59
- annotations: {
60
- "backstage.io/managed-by-location": MANAGED_BY_LOCATION,
61
- "backstage.io/managed-by-origin-location": MANAGED_BY_LOCATION,
62
- "bcdata.gov.bc.ca/organization-id": organization.id,
63
- "bcdata.gov.bc.ca/organization-type": organization.type,
64
- "bcdata.gov.bc.ca/organization-created": organization.created,
65
- "bcdata.gov.bc.ca/organization-approval-status": organization.approval_status,
66
- "bcdata.gov.bc.ca/organization-state": organization.state
67
- }
68
- }
69
- };
82
+ const systemEntity = this.systemEntityBuilder.build({
83
+ organization,
84
+ ownerGroupId: organizationGroupId
85
+ });
70
86
  allSystems.set(this.naming.getSystemId(organization.name), systemEntity);
71
87
  });
72
88
  const allUsers = /* @__PURE__ */ new Map();
@@ -75,23 +91,7 @@ class BcDataCatalogueEntityFactory {
75
91
  const email = contact.email.toLowerCase();
76
92
  let user = allUsers.get(email);
77
93
  if (user === void 0) {
78
- user = {
79
- apiVersion: "backstage.io/v1alpha1",
80
- kind: "User",
81
- spec: {
82
- profile: {
83
- email
84
- },
85
- memberOf: []
86
- },
87
- metadata: {
88
- name: this.naming.toSafeName(email),
89
- annotations: {
90
- "backstage.io/managed-by-location": MANAGED_BY_LOCATION,
91
- "backstage.io/managed-by-origin-location": MANAGED_BY_LOCATION
92
- }
93
- }
94
- };
94
+ user = this.userEntityBuilder.build({ email });
95
95
  allUsers.set(this.naming.getUserId(email), user);
96
96
  }
97
97
  if (user.spec.profile?.displayName === void 0) {
@@ -106,7 +106,11 @@ class BcDataCatalogueEntityFactory {
106
106
  const groupId = this.naming.getGroupId(hostName);
107
107
  let group = allGroups.get(groupId);
108
108
  if (group === void 0) {
109
- group = this.createGroupEntity(hostName, hostName, bcGovGroupId);
109
+ group = this.groupEntityBuilder.build({
110
+ hostName,
111
+ displayName: hostName,
112
+ parentGroup: bcGovGroupId
113
+ });
110
114
  allGroups.set(groupId, group);
111
115
  }
112
116
  if (!user.spec.memberOf?.includes(groupId)) {
@@ -117,313 +121,127 @@ class BcDataCatalogueEntityFactory {
117
121
  });
118
122
  const allDatasets = /* @__PURE__ */ new Map();
119
123
  const allApis = /* @__PURE__ */ new Map();
124
+ const allOpenApis = /* @__PURE__ */ new Map();
125
+ const openApiDefinitionToId = /* @__PURE__ */ new Map();
120
126
  for (const pkg of allPackages) {
121
127
  const safeName = this.naming.toSafeName(pkg.name);
122
128
  const systemId = this.naming.getSystemId(pkg.organization.name);
123
129
  const ownerGroupId = this.naming.getGroupId(pkg.organization.name);
124
- const learnMoreLinks = [];
125
- const relatedResources = [];
126
- const accessMethods = [];
127
130
  const apiResources = [];
128
131
  const providesApis = [];
129
- pkg.more_info?.forEach((moreInfo) => {
130
- if (moreInfo.url.length > 0) {
131
- const entityLink = {
132
- url: moreInfo.url,
133
- title: moreInfo.description || moreInfo.url,
134
- icon: "externalLink",
135
- type: "more_info"
136
- };
137
- learnMoreLinks.push(entityLink);
138
- relatedResources.push({
139
- url: moreInfo.url,
140
- title: moreInfo.description || moreInfo.url
141
- });
142
- }
143
- });
144
132
  pkg.resources?.forEach((resource) => {
145
- if (this.isApiResource(resource)) {
146
- apiResources.push(resource);
147
- return;
148
- }
149
- if (resource.bcdc_type === "geographic") {
133
+ const apiResourceCandidate = this.getApiResourceCandidate(resource);
134
+ if (apiResourceCandidate) {
135
+ apiResources.push(apiResourceCandidate);
150
136
  return;
151
137
  }
152
- if (resource.url.length > 0) {
153
- accessMethods.push(this.toDatasetAccessMethod(resource));
154
- relatedResources.push({
155
- url: resource.url,
156
- title: resource.name
157
- });
158
- } else {
159
- this.logger.info(
160
- `[BCDC Entity Factory] Missing URL ${resource.bcdc_type} ${resource.format} ${pkg.name}`
161
- );
162
- }
163
138
  });
164
- const tags = [];
165
- pkg.tags?.forEach((tag) => {
166
- tags.push(this.naming.toSafeName(tag.display_name));
139
+ const bcdcDatasetUrl = `https://catalogue.data.gov.bc.ca/dataset/${pkg.name}`;
140
+ const datasetEntity = this.datasetEntityBuilder.build({
141
+ pkg,
142
+ safeName,
143
+ ownerGroupId,
144
+ systemId,
145
+ providesApis,
146
+ bcdcDatasetUrl
167
147
  });
168
- const schema = this.schemaUtils.buildDatasetSchema(pkg.resources);
169
- const tableCount = schema?.tables?.length ?? 0;
170
- if (tableCount > 0) {
171
- tags.push("has-schema");
172
- } else {
173
- tags.push("has-no-schema");
174
- }
175
- if (tableCount >= 2 && tableCount <= 5) {
176
- tags.push("has-two-to-five-tables");
177
- } else if (tableCount >= 6 && tableCount <= 10) {
178
- tags.push("has-six-to-10-tables");
179
- } else if (tableCount >= 11) {
180
- tags.push("has-11-or-more-tables");
181
- }
182
- const datasetEntity = {
183
- apiVersion: pluginCatalogCommonBcDataCatalogue.DATASET_API_VERSION,
184
- kind: pluginCatalogCommonBcDataCatalogue.DATASET_KIND,
185
- spec: {
186
- owner: ownerGroupId,
187
- system: systemId,
188
- type: GAP + "<type>",
189
- description: pkg.notes || "No description available",
190
- status: this.normalizeStatus(pkg.publish_state),
191
- securityClassification: this.normalizeSecurityClassification(
192
- pkg.security_class
193
- ),
194
- connectedServicesDescription: GAP + "<connectedServicesDescription>",
195
- updateFrequency: GAP + "<updateFrequency>",
196
- providesApis,
197
- accessMethods: accessMethods.length > 0 ? accessMethods : void 0,
198
- schema,
199
- quality: {
200
- score: GAP + "<quality.score>",
201
- validation: GAP + "<quality.validation>",
202
- controls: [GAP + "<quality.controls>"]
203
- },
204
- governance: {
205
- retention: GAP + "<governance.retention>",
206
- description: GAP + "<governance.description>"
207
- },
208
- about: {
209
- description: pkg.purpose || "No description available"
210
- },
211
- authoritativeDesignation: {
212
- authoritativeFor: GAP + "<authoritativeDesignation.authoritativeFor>"
213
- },
214
- lineage: {
215
- sourceSystem: GAP + "<lineage.sourceSystem>",
216
- transformation: pkg.lineage_statement || "No transformation information available",
217
- refresh: GAP + "<lineage.refresh>"
218
- },
219
- versioning: {
220
- currentVersion: GAP + "<versioning.currentVersion>",
221
- // pkg.version is not populated for any of the 3000+ datasets
222
- initialRelease: pkg.record_publish_date,
223
- lastUpdated: pkg.record_last_modified,
224
- description: GAP + "<versioning.description>"
225
- },
226
- support: {
227
- description: pkg.organization.description,
228
- dataCustodian: pkg.organization.title,
229
- governanceAuthority: GAP + "<support.governanceAuthority>",
230
- pathways: GAP + "<support.pathways>",
231
- dataAndSemantics: {
232
- description: GAP + "<support.dataAndSemantics.description>",
233
- channel: GAP + "<support.dataAndSemantics.channel>",
234
- responseTime: GAP + "<support.dataAndSemantics.responseTime>",
235
- escalation: GAP + "<support.dataAndSemantics.escalation>"
236
- },
237
- accessAndIntegration: {
238
- description: GAP + "<support.accessAndIntegration.description>",
239
- channel: GAP + "<support.accessAndIntegration.channel>",
240
- responseTime: GAP + "<support.accessAndIntegration.responseTime>",
241
- escalation: GAP + "<support.accessAndIntegration.escalation>"
242
- },
243
- governanceAndProductionEscalation: {
244
- description: GAP + "<support.governanceAndProductionEscalation.description>",
245
- channel: GAP + "<support.governanceAndProductionEscalation.channel>",
246
- referenceDataset: GAP + "<support.governanceAndProductionEscalation.referenceDataset>",
247
- responseTime: GAP + "<support.governanceAndProductionEscalation.responseTime>"
248
- }
249
- },
250
- relatedResources: relatedResources.length > 0 ? relatedResources : void 0
251
- },
252
- metadata: {
253
- name: safeName,
254
- title: pkg.title || pkg.name,
255
- description: pkg.notes || "No description available",
256
- annotations: {
257
- "backstage.io/managed-by-location": MANAGED_BY_LOCATION,
258
- "backstage.io/managed-by-origin-location": MANAGED_BY_LOCATION,
259
- "bcdata.gov.bc.ca/package-author": pkg.author || "Unknown",
260
- "bcdata.gov.bc.ca/package-author_email": pkg.author_email || "Unknown",
261
- "bcdata.gov.bc.ca/package-creator_user_id": pkg.creator_user_id,
262
- "bcdata.gov.bc.ca/package-download_audience": pkg.download_audience,
263
- "bcdata.gov.bc.ca/package-id": pkg.id,
264
- "bcdata.gov.bc.ca/package-isopen": `${pkg.isopen}`,
265
- "bcdata.gov.bc.ca/package-license_id": pkg.license_id,
266
- "bcdata.gov.bc.ca/package-license_title": pkg.license_title || "Unknown",
267
- "bcdata.gov.bc.ca/package-license_url": pkg.license_url,
268
- "bcdata.gov.bc.ca/package-maintainer": pkg.maintainer || "Unknown",
269
- "bcdata.gov.bc.ca/package-maintainer_email": pkg.maintainer_email || "Unknown",
270
- "bcdata.gov.bc.ca/package-metadata_created": pkg.metadata_created,
271
- "bcdata.gov.bc.ca/package-metadata_modified": pkg.metadata_modified,
272
- "bcdata.gov.bc.ca/package-metadata_visibility": pkg.metadata_visibility,
273
- "bcdata.gov.bc.ca/package-name": pkg.name,
274
- "bcdata.gov.bc.ca/package-notes": pkg.notes || "Unknown",
275
- "bcdata.gov.bc.ca/package-owner_org": pkg.owner_org,
276
- "bcdata.gov.bc.ca/package-private": `${pkg.private}`,
277
- "bcdata.gov.bc.ca/package-publish_state": pkg.publish_state,
278
- "bcdata.gov.bc.ca/package-record_create_date": pkg.record_create_date || "Unknown",
279
- "bcdata.gov.bc.ca/package-record_last_modified": pkg.record_last_modified,
280
- "bcdata.gov.bc.ca/package-record_publish_date": pkg.record_publish_date,
281
- "bcdata.gov.bc.ca/package-resource_status": pkg.resource_status,
282
- "bcdata.gov.bc.ca/package-security_class": pkg.security_class,
283
- "bcdata.gov.bc.ca/package-state": pkg.state,
284
- "bcdata.gov.bc.ca/package-title": pkg.title || "Unknown",
285
- "bcdata.gov.bc.ca/package-type": pkg.type,
286
- "bcdata.gov.bc.ca/package-url": pkg.url || "Unknown",
287
- "bcdata.gov.bc.ca/package-version": pkg.version || "Unknown",
288
- "bcdata.gov.bc.ca/package-view_audience": pkg.view_audience
289
- },
290
- links: learnMoreLinks.length > 0 ? learnMoreLinks : void 0,
291
- tags
292
- }
293
- };
294
148
  allDatasets.set(this.naming.getComponentId(safeName), datasetEntity);
295
149
  let hasOpenApi = false;
296
- for (const apiResource of apiResources) {
150
+ for (const candidate of apiResources) {
151
+ const { apiResource, definitionUrl, definitionHost, isOpenApiCandidate } = candidate;
297
152
  const name = apiResource.name;
298
- let apiSafeName = this.naming.toSafeName(name);
299
- if (apiResource.bcdc_type === "webservice" || apiResource.format === "arcgis_rest") {
300
- const prefix = apiResource.format === "openapi-json" ? "api" : apiResource.format;
301
- const formatSafeName = this.naming.toSafeName(prefix);
302
- const baseName = this.naming.toSafeName(`${formatSafeName}-${safeName}`);
303
- let candidateName = baseName;
304
- let candidateApiId = this.naming.getApiId(candidateName);
305
- if (allApis.has(candidateApiId)) {
306
- const distinguishingSuffix = this.naming.extractDistinguishingSuffix(name);
307
- const suffixWithSeparator = `-${distinguishingSuffix}`;
308
- const maxBaseLength = 63 - suffixWithSeparator.length;
309
- let truncatedBase = baseName;
310
- if (baseName.length > maxBaseLength) {
311
- truncatedBase = baseName.slice(0, maxBaseLength).replace(/[-_.]+$/, "");
312
- }
313
- candidateName = this.naming.toSafeName(
314
- `${truncatedBase}${suffixWithSeparator}`
315
- );
316
- candidateApiId = this.naming.getApiId(candidateName);
317
- let counter = 1;
318
- while (allApis.has(candidateApiId)) {
319
- const counterSuffix = `-${counter}`;
320
- const maxBaseWithCounter = 63 - suffixWithSeparator.length - counterSuffix.length;
321
- let truncatedBaseForCounter = baseName;
322
- if (baseName.length > maxBaseWithCounter) {
323
- truncatedBaseForCounter = baseName.slice(0, maxBaseWithCounter).replace(/[-_.]+$/, "");
324
- }
325
- candidateName = this.naming.toSafeName(
326
- `${truncatedBaseForCounter}${suffixWithSeparator}${counterSuffix}`
327
- );
328
- candidateApiId = this.naming.getApiId(candidateName);
329
- counter++;
330
- }
331
- }
332
- apiSafeName = candidateName;
333
- }
334
- const definition = apiResource.url;
335
- const url = new URL(definition);
336
- const host = url.host.toLowerCase();
337
- if (!this.allowedHosts.includes(host)) {
153
+ if (definitionHost && !this.allowedHosts.includes(definitionHost)) {
338
154
  this.logger.warn(
339
- `[BCDC Entity Factory] API definition host is NOT allowed: "${host}"`
155
+ `[BCDC Entity Factory] API definition host is NOT allowed: "${definitionHost}"`
340
156
  );
341
157
  }
342
- const apiEntityLinks = [];
343
- if (apiResource.url && apiResource.url.length > 0) {
344
- const apiEntityLink = {
345
- url: apiResource.url,
346
- title: apiResource.name,
347
- icon: "api",
348
- type: apiResource.bcdc_type
349
- };
350
- apiEntityLinks.push(apiEntityLink);
158
+ let definitionContent = definitionUrl;
159
+ let isOpenApiResource = false;
160
+ if (isOpenApiCandidate) {
161
+ const fetchedDefinition = await this.tryReadDefinition(definitionUrl);
162
+ if (fetchedDefinition !== void 0) {
163
+ definitionContent = fetchedDefinition;
164
+ isOpenApiResource = await pluginCatalogCommonBcDataCatalogue.parseOpenApiDocument(fetchedDefinition) !== void 0;
165
+ }
166
+ if (isOpenApiResource) {
167
+ hasOpenApi = true;
168
+ }
351
169
  }
352
- let definitionContent = apiResource.url;
353
- if (apiResource.format === "openapi-json") {
354
- hasOpenApi = true;
355
- try {
356
- const response = await this.reader.readUrl(apiResource.url);
357
- const content = (await response.buffer()).toString();
358
- definitionContent = content;
359
- } catch (error) {
170
+ const bcdcDatasetResourceUrl = `${bcdcDatasetUrl}/resource/${apiResource.id}`;
171
+ if (isOpenApiResource) {
172
+ const normalizedDefinitionUrl = this.normalizeOpenApiDefinitionUrl(definitionUrl);
173
+ const existingOpenApiId = openApiDefinitionToId.get(normalizedDefinitionUrl);
174
+ if (existingOpenApiId) {
175
+ const existingOpenApi = allOpenApis.get(existingOpenApiId);
360
176
  this.logger.warn(
361
- `[BCDC Entity Factory] Failed to fetch OpenAPI definition from ${apiResource.url}: ${error}`
177
+ `[BCDC Entity Factory] Duplicate OpenApi definition detected for "${normalizedDefinitionUrl}". Existing OpenApi: name="${existingOpenApi.metadata.name}", owner="${existingOpenApi.spec.owner}", system="${existingOpenApi.spec.system ?? ""}", bcdc_type="${existingOpenApi.metadata.annotations?.["bcdata.gov.bc.ca/resource-bcdc_type"]}", format="${existingOpenApi.metadata.annotations?.["bcdata.gov.bc.ca/resource-format"]}", resource-url="${existingOpenApi.metadata.annotations?.["bcdata.gov.bc.ca/resource-url"]}", openapi-url="${normalizedDefinitionUrl}". New OpenApi resource: name="${apiResource.name}", owner="${ownerGroupId}", system="${systemId}", bcdc_type="${apiResource.bcdc_type}", format="${apiResource.format}", resource-url="${apiResource.url}", openapi-url="${normalizedDefinitionUrl}". Reusing existing OpenApi entity.`
362
178
  );
363
- definitionContent = apiResource.url;
364
- }
365
- }
366
- const apiEntity = {
367
- apiVersion: "backstage.io/v1alpha1",
368
- kind: "API",
369
- spec: {
370
- type: apiResource.format === "openapi-json" ? "openapi" : apiResource.bcdc_type,
371
- lifecycle: "production",
372
- owner: ownerGroupId,
373
- definition: definitionContent,
374
- system: systemId
375
- },
376
- metadata: {
377
- name: apiSafeName,
378
- description: apiResource.description || "No description available",
379
- links: apiEntityLinks,
380
- tags: [this.naming.toSafeName(apiResource.format)],
381
- annotations: {
382
- "backstage.io/managed-by-location": MANAGED_BY_LOCATION,
383
- "backstage.io/managed-by-origin-location": MANAGED_BY_LOCATION,
384
- "bcdata.gov.bc.ca/resource-bcdc_type": apiResource.bcdc_type,
385
- "bcdata.gov.bc.ca/resource-cache_last_updated": apiResource.cache_last_updated || "Undefined",
386
- "bcdata.gov.bc.ca/resource-cache_url": apiResource.cache_url || "Undefined",
387
- "bcdata.gov.bc.ca/resource-created": apiResource.created,
388
- "bcdata.gov.bc.ca/resource-datastore_active": `${apiResource.datastore_active}`,
389
- "bcdata.gov.bc.ca/resource-description": apiResource.description || "Undefined",
390
- "bcdata.gov.bc.ca/resource-format": apiResource.format,
391
- "bcdata.gov.bc.ca/resource-hash": apiResource.hash,
392
- "bcdata.gov.bc.ca/resource-id": apiResource.id,
393
- "bcdata.gov.bc.ca/resource-metadata_modified": apiResource.metadata_modified,
394
- "bcdata.gov.bc.ca/resource-mimetype": apiResource.mimetype || "Undefined",
395
- "bcdata.gov.bc.ca/resource-name": apiResource.name,
396
- "bcdata.gov.bc.ca/resource-package_id": apiResource.package_id,
397
- "bcdata.gov.bc.ca/resource-position": `${apiResource.position}`,
398
- "bcdata.gov.bc.ca/resource-projection_name": apiResource.projection_name || "Undefined",
399
- "bcdata.gov.bc.ca/resource-resource_access_method": apiResource.resource_access_method,
400
- "bcdata.gov.bc.ca/resource-resource_storage_location": apiResource.resource_storage_location,
401
- "bcdata.gov.bc.ca/resource-resource_type": apiResource.resource_type,
402
- "bcdata.gov.bc.ca/resource-resource_update_cycle": apiResource.resource_update_cycle,
403
- "bcdata.gov.bc.ca/resource-size": `${apiResource.size}`,
404
- "bcdata.gov.bc.ca/resource-spatial_datatype": apiResource.spatial_datatype || "Undefined",
405
- "bcdata.gov.bc.ca/resource-state": apiResource.state,
406
- "bcdata.gov.bc.ca/resource-url": apiResource.url,
407
- "bcdata.gov.bc.ca/resource-url_type": apiResource.url_type || "Undefined"
179
+ if (!datasetEntity.spec.providesApis?.includes(existingOpenApiId)) {
180
+ datasetEntity.spec.providesApis?.push(existingOpenApiId);
408
181
  }
182
+ continue;
409
183
  }
410
- };
411
- const apiId = this.naming.getApiId(apiSafeName);
412
- if (allApis.has(apiId)) {
413
- const existingApi = allApis.get(apiId);
414
- this.logger.warn(
415
- `[BCDC Entity Factory] Duplicate API name detected: "${apiSafeName}" (ID: ${apiId}). Existing API: name="${existingApi.metadata.name}", Format: "${existingApi.metadata.annotations?.["bcdata.gov.bc.ca/resource-format"]}", resource-id="${existingApi.metadata.annotations?.["bcdata.gov.bc.ca/resource-id"]}", package-id="${existingApi.metadata.annotations?.["bcdata.gov.bc.ca/resource-package_id"]}", url="${existingApi.metadata.annotations?.["bcdata.gov.bc.ca/resource-url"]}". New API: name="${apiEntity.metadata.name}", resource-id="${apiResource.id}", package-id="${apiResource.package_id}", url="${apiResource.url}". The new API will overwrite the existing one.`
184
+ const openApiSafeName = this.buildOpenApiSafeName(
185
+ name,
186
+ definitionHost,
187
+ allOpenApis
416
188
  );
189
+ const openApiId = this.naming.getOpenApiId(openApiSafeName);
190
+ const openApiEntity = await this.openApiEntityBuilder.build({
191
+ pkg,
192
+ apiResource,
193
+ ownerGroupId,
194
+ systemId,
195
+ openApiSafeName,
196
+ datasetEntityRef: this.naming.getDatasetId(safeName),
197
+ definitionUrl,
198
+ definition: definitionContent,
199
+ bcdcDatasetResourceUrl
200
+ });
201
+ allOpenApis.set(openApiId, openApiEntity);
202
+ openApiDefinitionToId.set(normalizedDefinitionUrl, openApiId);
203
+ datasetEntity.spec.providesApis?.push(openApiId);
204
+ continue;
205
+ }
206
+ const apiSafeName = this.buildApiSafeName(name, definitionHost);
207
+ const apiId = this.naming.getApiId(apiSafeName);
208
+ const existingApi = allApis.get(apiId);
209
+ const expectedApiType = apiResource.bcdc_type;
210
+ if (existingApi) {
211
+ if (existingApi.spec.type !== expectedApiType || existingApi.spec.owner !== ownerGroupId || existingApi.spec.system !== systemId) {
212
+ this.logger.warn(
213
+ `[BCDC Entity Factory] Duplicate API identity detected for "${apiSafeName}" (ID: ${apiId}). Existing API: type="${existingApi.spec.type}", owner="${existingApi.spec.owner}", system="${existingApi.spec.system ?? ""}", bcdc_type="${existingApi.metadata.annotations?.["bcdata.gov.bc.ca/resource-bcdc_type"]}", format="${existingApi.metadata.annotations?.["bcdata.gov.bc.ca/resource-format"]}", resource-id="${existingApi.metadata.annotations?.["bcdata.gov.bc.ca/resource-id"]}", package-id="${existingApi.metadata.annotations?.["bcdata.gov.bc.ca/resource-package_id"]}", url="${existingApi.metadata.annotations?.["bcdata.gov.bc.ca/resource-url"]}". New API: type="${expectedApiType}", owner="${ownerGroupId}", system="${systemId}", bcdc_type="${apiResource.bcdc_type}", format="${apiResource.format}", resource-id="${apiResource.id}", package-id="${apiResource.package_id}", url="${apiResource.url}". Reusing existing API entity.`
214
+ );
215
+ }
216
+ if (!datasetEntity.spec.providesApis?.includes(apiId)) {
217
+ datasetEntity.spec.providesApis?.push(apiId);
218
+ }
219
+ continue;
417
220
  }
221
+ const apiEntity = this.apiEntityBuilder.build({
222
+ apiResource,
223
+ ownerGroupId,
224
+ systemId,
225
+ apiSafeName,
226
+ definition: definitionContent,
227
+ apiType: expectedApiType,
228
+ bcdcDatasetResourceUrl
229
+ });
418
230
  allApis.set(apiId, apiEntity);
419
231
  datasetEntity.spec.providesApis?.push(apiId);
420
232
  }
421
233
  if (datasetEntity.spec.providesApis?.length) {
422
- if (!tags.includes("has-api")) {
423
- tags.push("has-api");
234
+ if (!datasetEntity.metadata.tags?.includes("has-api")) {
235
+ datasetEntity.metadata.tags = [
236
+ ...datasetEntity.metadata.tags ?? [],
237
+ "has-api"
238
+ ];
424
239
  }
425
- if (hasOpenApi && !tags.includes("has-openapi")) {
426
- tags.push("has-openapi");
240
+ if (hasOpenApi && !datasetEntity.metadata.tags?.includes("has-openapi")) {
241
+ datasetEntity.metadata.tags = [
242
+ ...datasetEntity.metadata.tags ?? [],
243
+ "has-openapi"
244
+ ];
427
245
  }
428
246
  }
429
247
  }
@@ -432,78 +250,132 @@ class BcDataCatalogueEntityFactory {
432
250
  const systemEntities = Array.from(allSystems.values());
433
251
  const datasetEntities = Array.from(allDatasets.values());
434
252
  const apiEntities = Array.from(allApis.values());
253
+ const openApiEntities = Array.from(allOpenApis.values());
435
254
  allEntities.push(...userEntities);
436
255
  allEntities.push(...groupEntities);
437
256
  allEntities.push(...systemEntities);
438
257
  allEntities.push(...datasetEntities);
439
258
  allEntities.push(...apiEntities);
440
- this.logger.info(`[BCDC Entity Factory] >createEntities ${allEntities.length}`);
259
+ allEntities.push(...openApiEntities);
260
+ this.logger.info(
261
+ `[BCDC Entity Factory] >createEntities ${allEntities.length}`
262
+ );
441
263
  return allEntities;
442
264
  }
443
- isApiResource(resource) {
444
- return resource.bcdc_type === "webservice" && resource.format !== "kml" || resource.format === "arcgis_rest" || resource.format === "openapi-json";
265
+ buildOpenApiSafeName(resourceName, host, allOpenApis) {
266
+ const baseName = host ? this.naming.toSafeName(`${resourceName}-${host}`) : this.naming.toSafeName(resourceName);
267
+ let candidateName = baseName;
268
+ let candidateOpenApiId = this.naming.getOpenApiId(candidateName);
269
+ if (!allOpenApis.has(candidateOpenApiId)) {
270
+ return candidateName;
271
+ }
272
+ const distinguishingSuffix = host && host.length > 0 ? this.naming.toSafeName(host) : this.naming.extractDistinguishingSuffix(resourceName);
273
+ const suffixWithSeparator = `-${distinguishingSuffix}`;
274
+ const maxBaseLength = 63 - suffixWithSeparator.length;
275
+ let truncatedBase = baseName;
276
+ if (baseName.length > maxBaseLength) {
277
+ truncatedBase = baseName.slice(0, maxBaseLength).replace(/[-_.]+$/, "");
278
+ }
279
+ candidateName = this.naming.toSafeName(
280
+ `${truncatedBase}${suffixWithSeparator}`
281
+ );
282
+ candidateOpenApiId = this.naming.getOpenApiId(candidateName);
283
+ let counter = 1;
284
+ while (allOpenApis.has(candidateOpenApiId)) {
285
+ const counterSuffix = `-${counter}`;
286
+ const maxBaseWithCounter = 63 - suffixWithSeparator.length - counterSuffix.length;
287
+ let truncatedBaseForCounter = baseName;
288
+ if (baseName.length > maxBaseWithCounter) {
289
+ truncatedBaseForCounter = baseName.slice(0, maxBaseWithCounter).replace(/[-_.]+$/, "");
290
+ }
291
+ candidateName = this.naming.toSafeName(
292
+ `${truncatedBaseForCounter}${suffixWithSeparator}${counterSuffix}`
293
+ );
294
+ candidateOpenApiId = this.naming.getOpenApiId(candidateName);
295
+ counter++;
296
+ }
297
+ return candidateName;
298
+ }
299
+ buildApiSafeName(resourceName, host) {
300
+ return host ? this.naming.toSafeName(`${resourceName}-${host}`) : this.naming.toSafeName(resourceName);
445
301
  }
446
- toDatasetAccessMethod(resource) {
302
+ getApiResourceCandidate(resource) {
303
+ const definitionUrl = this.getDefinitionUrl(resource.url);
304
+ if (!definitionUrl) {
305
+ return void 0;
306
+ }
307
+ const definitionHost = this.getUrlHost(definitionUrl);
308
+ const isOpenApiCandidate = resource.format === "openapi-json" || resource.bcdc_type === "webservice" && ["json", "xml", "html"].includes(resource.format) && this.looksLikeOpenApiUrl(resource.url, definitionUrl);
309
+ if (isOpenApiCandidate) {
310
+ return {
311
+ apiResource: resource,
312
+ definitionUrl,
313
+ definitionHost,
314
+ isOpenApiCandidate: true
315
+ };
316
+ }
317
+ if (resource.bcdc_type !== "webservice") {
318
+ return void 0;
319
+ }
320
+ if (["kml", "wms", "arcgis_rest", "xml"].includes(resource.format)) {
321
+ return void 0;
322
+ }
447
323
  return {
448
- id: resource.id,
449
- title: resource.name,
450
- description: resource.description,
451
- url: resource.url,
452
- type: resource.bcdc_type,
453
- format: resource.format,
454
- updateFrequency: resource.resource_update_cycle
324
+ apiResource: resource,
325
+ definitionUrl,
326
+ definitionHost,
327
+ isOpenApiCandidate: false
455
328
  };
456
329
  }
457
- normalizeStatus(publishState) {
458
- switch (publishState.trim().toUpperCase()) {
459
- case "PUBLISHED":
460
- return "Published";
461
- case "PENDING ARCHIVE":
462
- return "Pending Archive";
463
- default:
464
- return "Unknown";
330
+ getDefinitionUrl(resourceUrl) {
331
+ const trimmedUrl = resourceUrl?.trim();
332
+ if (!trimmedUrl) {
333
+ return void 0;
334
+ }
335
+ try {
336
+ const parsedUrl = new URL(trimmedUrl);
337
+ const nestedUrl = parsedUrl.searchParams.get("url")?.trim();
338
+ if (nestedUrl) {
339
+ return nestedUrl;
340
+ }
341
+ return trimmedUrl;
342
+ } catch {
343
+ return void 0;
465
344
  }
466
345
  }
467
- normalizeSecurityClassification(securityClass) {
468
- switch (securityClass.trim().toUpperCase()) {
469
- case "PUBLIC":
470
- return "Public";
471
- case "PROTECTED A":
472
- return "Protected A";
473
- case "PROTECTED B":
474
- return "Protected B";
475
- case "PROTECTED C":
476
- return "Protected C";
477
- default:
478
- return "Unknown";
346
+ getUrlHost(url) {
347
+ try {
348
+ return new URL(url).host.toLowerCase();
349
+ } catch {
350
+ return void 0;
351
+ }
352
+ }
353
+ normalizeOpenApiDefinitionUrl(url) {
354
+ return url.trim().toLowerCase();
355
+ }
356
+ looksLikeOpenApiUrl(resourceUrl, definitionUrl) {
357
+ const raw = resourceUrl.toLowerCase();
358
+ const normalized = definitionUrl.toLowerCase();
359
+ if (raw.includes("oas-editor.apps.gov.bc.ca/?url=")) {
360
+ return true;
361
+ }
362
+ return normalized.includes("openapi") || normalized.includes("swagger") || normalized.includes("/api-specs/");
363
+ }
364
+ async tryReadDefinition(url) {
365
+ try {
366
+ const response = await this.reader.readUrl(url);
367
+ return (await response.buffer()).toString();
368
+ } catch (error) {
369
+ this.logger.warn(
370
+ `[BCDC Entity Factory] Failed to fetch API definition from ${url}: ${error}`
371
+ );
372
+ return void 0;
479
373
  }
480
374
  }
481
375
  getEmailHostname(email) {
482
376
  const match = email.trim().toLowerCase().match(/@([\w.-]+)/);
483
377
  return match ? match[1] : void 0;
484
378
  }
485
- createGroupEntity(hostName, displayName, parentGroup) {
486
- return {
487
- apiVersion: "backstage.io/v1alpha1",
488
- kind: "Group",
489
- metadata: {
490
- name: this.naming.toSafeName(hostName),
491
- annotations: {
492
- "backstage.io/managed-by-location": MANAGED_BY_LOCATION,
493
- "backstage.io/managed-by-origin-location": MANAGED_BY_LOCATION
494
- }
495
- },
496
- spec: {
497
- type: "government",
498
- profile: {
499
- displayName
500
- },
501
- parent: parentGroup,
502
- children: [],
503
- members: []
504
- }
505
- };
506
- }
507
379
  }
508
380
 
509
381
  exports.BcDataCatalogueEntityFactory = BcDataCatalogueEntityFactory;