@twin.org/federated-catalogue-service 0.0.3-next.2 → 0.0.3-next.21

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 (37) hide show
  1. package/README.md +1 -1
  2. package/dist/es/entities/dataset.js +12 -4
  3. package/dist/es/entities/dataset.js.map +1 -1
  4. package/dist/es/federatedCatalogueRoutes.js +241 -53
  5. package/dist/es/federatedCatalogueRoutes.js.map +1 -1
  6. package/dist/es/index.js +2 -0
  7. package/dist/es/index.js.map +1 -1
  8. package/dist/es/models/IFederatedCatalogueServiceConfig.js +4 -0
  9. package/dist/es/models/IFederatedCatalogueServiceConfig.js.map +1 -0
  10. package/dist/es/models/IFederatedCatalogueServiceConstructorOptions.js.map +1 -1
  11. package/dist/es/services/federatedCatalogueService.js +344 -131
  12. package/dist/es/services/federatedCatalogueService.js.map +1 -1
  13. package/dist/es/utils/catalogErrorUtils.js +49 -0
  14. package/dist/es/utils/catalogErrorUtils.js.map +1 -0
  15. package/dist/es/utils/datasetConverters.js +23 -10
  16. package/dist/es/utils/datasetConverters.js.map +1 -1
  17. package/dist/types/entities/dataset.d.ts +37 -33
  18. package/dist/types/index.d.ts +2 -0
  19. package/dist/types/models/IFederatedCatalogueServiceConfig.d.ts +5 -0
  20. package/dist/types/models/IFederatedCatalogueServiceConstructorOptions.d.ts +14 -4
  21. package/dist/types/services/federatedCatalogueService.d.ts +37 -21
  22. package/dist/types/utils/catalogErrorUtils.d.ts +15 -0
  23. package/dist/types/utils/datasetConverters.d.ts +10 -10
  24. package/docs/changelog.md +271 -3
  25. package/docs/examples.md +126 -1
  26. package/docs/open-api/spec.json +256 -2513
  27. package/docs/reference/classes/Dataset.md +70 -62
  28. package/docs/reference/classes/FederatedCatalogueService.md +77 -40
  29. package/docs/reference/functions/datasetEntityToModel.md +7 -7
  30. package/docs/reference/functions/datasetModelToEntity.md +11 -6
  31. package/docs/reference/functions/transformErrorToStatusCode.md +19 -0
  32. package/docs/reference/functions/transformToCatalogError.md +20 -0
  33. package/docs/reference/index.md +3 -0
  34. package/docs/reference/interfaces/IFederatedCatalogueServiceConfig.md +3 -0
  35. package/docs/reference/interfaces/IFederatedCatalogueServiceConstructorOptions.md +33 -5
  36. package/locales/en.json +12 -6
  37. package/package.json +10 -5
@@ -1,14 +1,19 @@
1
1
  // Copyright 2025 IOTA Stiftung.
2
2
  // SPDX-License-Identifier: Apache-2.0.
3
- import { BaseError, Converter, GeneralError, Guards, Is, NotFoundError, ObjectHelper } from "@twin.org/core";
3
+ import { HttpUrlHelper } from "@twin.org/api-models";
4
+ import { ContextIdKeys } from "@twin.org/context";
5
+ import { ArrayHelper, BaseError, ComponentFactory, Converter, GeneralError, Guards, Is, JsonHelper, Mutex, NotFoundError, ObjectHelper, Url, Urn, Validation } from "@twin.org/core";
4
6
  import { Blake2b } from "@twin.org/crypto";
5
- import { JsonLdProcessor } from "@twin.org/data-json-ld";
7
+ import { JsonLdHelper, JsonLdProcessor } from "@twin.org/data-json-ld";
6
8
  import { EntityStorageConnectorFactory } from "@twin.org/entity-storage-models";
7
- import { FederatedCatalogueContexts, FederatedCatalogueFilterFactory } from "@twin.org/federated-catalogue-models";
8
- import { DataspaceProtocolContexts, DataspaceProtocolDataTypes } from "@twin.org/standards-dataspace-protocol";
9
+ import { FederatedCatalogueFilterFactory } from "@twin.org/federated-catalogue-models";
10
+ import { DataspaceProtocolCatalogTypes, DataspaceProtocolContexts, DataspaceProtocolDataTypes, DataspaceProtocolHelper } from "@twin.org/standards-dataspace-protocol";
9
11
  import { DublinCoreContexts, DublinCoreDataTypes } from "@twin.org/standards-dublin-core";
10
12
  import { FoafDataTypes } from "@twin.org/standards-foaf";
11
- import { DcatClasses, DcatContexts, DcatDataTypes } from "@twin.org/standards-w3c-dcat";
13
+ import { DcatContexts, DcatDataTypes } from "@twin.org/standards-w3c-dcat";
14
+ import { OdrlContexts } from "@twin.org/standards-w3c-odrl";
15
+ import { TrustHelper } from "@twin.org/trust-models";
16
+ import { transformToCatalogError } from "../utils/catalogErrorUtils.js";
12
17
  import { datasetEntityToModel, datasetModelToEntity } from "../utils/datasetConverters.js";
13
18
  /**
14
19
  * Service for managing federated catalogue operations.
@@ -29,18 +34,26 @@ export class FederatedCatalogueService {
29
34
  * @internal
30
35
  */
31
36
  _datasetStorage;
37
+ /**
38
+ * The trust component for token verification and generation.
39
+ * @internal
40
+ */
41
+ _trustComponent;
32
42
  /**
33
43
  * Create a new instance of FederatedCatalogueService.
34
44
  * @param options The options for the service.
35
45
  */
36
46
  constructor(options) {
37
- this._logging = options?.loggingComponent;
38
- this._datasetStorage = EntityStorageConnectorFactory.get(options?.datasetStorageConnectorType ?? "dataset");
47
+ this._logging = ComponentFactory.getIfExists(options?.loggingComponentType);
48
+ this._datasetStorage = EntityStorageConnectorFactory.get(options?.datasetEntityStorageType ?? "dataset");
49
+ this._trustComponent = ComponentFactory.get(options?.trustComponentType ?? "trust");
39
50
  // Register JSON-LD redirects for offline processing
40
51
  DcatDataTypes.registerRedirects();
41
52
  DublinCoreDataTypes.registerRedirects();
42
53
  FoafDataTypes.registerRedirects();
43
54
  DataspaceProtocolDataTypes.registerRedirects();
55
+ // Register DS Protocol data types for conformance checking
56
+ DataspaceProtocolDataTypes.registerTypes();
44
57
  }
45
58
  /**
46
59
  * Returns the class name of the component.
@@ -51,168 +64,368 @@ export class FederatedCatalogueService {
51
64
  }
52
65
  /**
53
66
  * Retrieve a dataset by its unique identifier.
54
- * @param dataSetId The unique identifier of the dataset.
55
- * @returns The dataset if found.
56
- * @throws NotFoundError if the dataset does not exist.
67
+ * @param datasetId The unique identifier of the dataset.
68
+ * @param trustPayload Optional payload for trust evaluation, if applicable.
69
+ * @returns The dataset if found, or a CatalogError if not found or an error occurs.
57
70
  */
58
- async get(dataSetId) {
59
- Guards.stringValue(FederatedCatalogueService.CLASS_NAME, "dataSetId", dataSetId);
60
- await this._logging?.log({
61
- level: "info",
62
- source: FederatedCatalogueService.CLASS_NAME,
63
- ts: Date.now(),
64
- message: "datasetRetrieve",
65
- data: { dataSetId }
66
- });
67
- const datasetEntity = await this._datasetStorage.get(dataSetId);
68
- if (!datasetEntity) {
69
- throw new NotFoundError(FederatedCatalogueService.CLASS_NAME, "datasetNotFound", dataSetId);
71
+ async get(datasetId, trustPayload) {
72
+ try {
73
+ Guards.stringValue(FederatedCatalogueService.CLASS_NAME, "datasetId", datasetId);
74
+ await TrustHelper.verifyTrust(this._trustComponent, trustPayload, "get");
75
+ await this._logging?.log({
76
+ level: "info",
77
+ source: FederatedCatalogueService.CLASS_NAME,
78
+ ts: Date.now(),
79
+ message: "datasetRetrieve",
80
+ data: { datasetId }
81
+ });
82
+ const datasetEntity = await this._datasetStorage.get(datasetId);
83
+ if (!datasetEntity) {
84
+ throw new NotFoundError(FederatedCatalogueService.CLASS_NAME, "datasetNotFound", datasetId);
85
+ }
86
+ const dataset = datasetEntityToModel(datasetEntity);
87
+ // Normalize to DS Protocol compliant format
88
+ // This ensures the payload matches exactly what the DS Protocol mandates
89
+ const normalizedDataset = await DataspaceProtocolHelper.normalize(JsonLdHelper.toNodeObject(dataset));
90
+ const structured = JsonLdHelper.toStructuredObject(normalizedDataset);
91
+ return structured;
92
+ }
93
+ catch (error) {
94
+ return transformToCatalogError(error);
70
95
  }
71
- return datasetEntityToModel(datasetEntity);
72
96
  }
73
97
  /**
74
98
  * Insert or update a dataset in the catalogue.
75
- * This method is internal and should not be exposed via REST endpoints.
76
- * @param dataSet The dataset to store.
99
+ * @param dataset The dataset to store.
100
+ * @param trustPayload Optional payload for trust evaluation, if applicable.
101
+ * @returns The unique identifier of the stored dataset, or a CatalogError if an error occurs.
77
102
  */
78
- async set(dataSet) {
79
- Guards.object(FederatedCatalogueService.CLASS_NAME, "dataSet", dataSet);
80
- const dataSetId = dataSet["@id"] ?? dataSet["dcterms:identifier"];
81
- Guards.stringValue(FederatedCatalogueService.CLASS_NAME, "dataSetId", dataSetId);
82
- await this._logging?.log({
83
- level: "info",
84
- source: FederatedCatalogueService.CLASS_NAME,
85
- ts: Date.now(),
86
- message: "datasetSet",
87
- data: { dataSetId }
88
- });
89
- const datasetEntity = datasetModelToEntity(dataSet);
90
- const allIndexes = {};
91
- const filterNames = FederatedCatalogueFilterFactory.names();
92
- for (const filterType of filterNames) {
103
+ async set(dataset, trustPayload) {
104
+ try {
105
+ Guards.object(FederatedCatalogueService.CLASS_NAME, "dataset", dataset);
106
+ const trustInfo = await TrustHelper.verifyTrust(this._trustComponent, trustPayload, "set");
107
+ // Normalize @id from dcterms:identifier if provided
108
+ const datasetId = dataset["@id"] ?? dataset["dcterms:identifier"];
109
+ Guards.stringValue(FederatedCatalogueService.CLASS_NAME, "datasetId", datasetId);
110
+ // Set @id if it was derived from dcterms:identifier
111
+ if (Is.empty(dataset["@id"]) && !Is.empty(dataset["dcterms:identifier"])) {
112
+ dataset["@id"] = datasetId;
113
+ }
114
+ // Validate @id is a valid URI (URN or URL) per DS Protocol
115
+ const isValidUrn = !Is.empty(Urn.tryParseExact(datasetId));
116
+ const isValidUrl = !Is.empty(Url.tryParseExact(datasetId));
117
+ if (!isValidUrn && !isValidUrl) {
118
+ throw new GeneralError(FederatedCatalogueService.CLASS_NAME, "datasetIdInvalidUri", {
119
+ datasetId
120
+ });
121
+ }
122
+ // Validate @type exists
123
+ Guards.stringValue(FederatedCatalogueService.CLASS_NAME, "@type", dataset["@type"]);
124
+ // Validate dcterms:publisher exists (required for multi-participant catalog)
125
+ // The publisher is used to derive participantId when returning catalog query results
126
+ const publisher = dataset["dcterms:publisher"];
127
+ if (Is.empty(publisher)) {
128
+ throw new GeneralError(FederatedCatalogueService.CLASS_NAME, "datasetMissingPublisher", {
129
+ datasetId
130
+ });
131
+ }
132
+ // DS Protocol compliance validation
133
+ const validationFailures = await DataspaceProtocolHelper.validate(JsonLdHelper.toNodeObject(dataset));
134
+ Validation.asValidationError(FederatedCatalogueService.CLASS_NAME, "dataset", validationFailures);
135
+ // Normalize dataset for storage using JSON-LD compaction
136
+ // This ensures the dataset uses prefixed properties that entity storage expects
137
+ // Entity storage schema uses DCAT-prefixed properties (dcat:distribution, not distribution)
138
+ // Use a standard context with prefixes to ensure proper normalization
139
+ const storageContext = {
140
+ dcat: DcatContexts.Namespace,
141
+ dcterms: DublinCoreContexts.NamespaceTerms,
142
+ odrl: OdrlContexts.Namespace
143
+ };
144
+ const normalizedDataset = await JsonLdProcessor.compact(dataset, storageContext);
145
+ const datasetEntity = datasetModelToEntity(normalizedDataset, trustInfo.identity);
146
+ // Serialise the read-check-write cycle so concurrent requests for the
147
+ // same dataset cannot both pass the ownership check and overwrite each other.
148
+ await Mutex.lock(datasetId);
93
149
  try {
94
- const filter = FederatedCatalogueFilterFactory.get(filterType);
95
- const filterIndexes = await filter.createIndex(dataSet);
96
- allIndexes[filterType] = filterIndexes;
150
+ const existingEntity = await this._datasetStorage.get(datasetId);
151
+ if (!Is.empty(existingEntity) && existingEntity.ownerId !== trustInfo.identity) {
152
+ throw new GeneralError(FederatedCatalogueService.CLASS_NAME, "datasetOwnerMismatch", {
153
+ datasetId
154
+ });
155
+ }
156
+ // Bake the publishing organization into distribution accessService URLs
157
+ await this.bakeOrganizationIntoDistributions(datasetEntity, trustInfo.identity);
97
158
  await this._logging?.log({
98
159
  level: "info",
99
160
  source: FederatedCatalogueService.CLASS_NAME,
100
161
  ts: Date.now(),
101
- message: "filterIndexPersisted",
102
- data: { dataSetId, filterType, indexCount: Object.keys(filterIndexes).length }
162
+ message: "datasetSet",
163
+ data: { datasetId }
103
164
  });
165
+ const filterNames = FederatedCatalogueFilterFactory.names();
166
+ for (const filterType of filterNames) {
167
+ try {
168
+ const filter = FederatedCatalogueFilterFactory.get(filterType);
169
+ const filterIndexes = await filter.createIndex(normalizedDataset);
170
+ await this._logging?.log({
171
+ level: "info",
172
+ source: FederatedCatalogueService.CLASS_NAME,
173
+ ts: Date.now(),
174
+ message: "filterIndexPersisted",
175
+ data: { datasetId, filterType, indexCount: Object.keys(filterIndexes).length }
176
+ });
177
+ }
178
+ catch (error) {
179
+ await this._logging?.log({
180
+ level: "error",
181
+ source: FederatedCatalogueService.CLASS_NAME,
182
+ ts: Date.now(),
183
+ message: "filterIndexCreationFailed",
184
+ data: { datasetId, filterType },
185
+ error: BaseError.fromError(error)
186
+ });
187
+ }
188
+ }
189
+ await this._datasetStorage.set(datasetEntity);
190
+ return datasetId;
104
191
  }
105
- catch (error) {
192
+ finally {
193
+ Mutex.unlock(datasetId);
194
+ }
195
+ }
196
+ catch (error) {
197
+ return transformToCatalogError(error);
198
+ }
199
+ }
200
+ /**
201
+ * Remove a dataset from the catalogue by its unique identifier.
202
+ * Indexes are automatically removed as they are stored with the dataset.
203
+ * @param datasetId The unique identifier of the dataset to remove.
204
+ * @param trustPayload Optional payload for trust evaluation, if applicable.
205
+ * @returns Nothing, or a CatalogError if an error occurs.
206
+ */
207
+ async remove(datasetId, trustPayload) {
208
+ try {
209
+ Guards.stringValue(FederatedCatalogueService.CLASS_NAME, "datasetId", datasetId);
210
+ const trustInfo = await TrustHelper.verifyTrust(this._trustComponent, trustPayload, "remove");
211
+ await Mutex.lock(datasetId);
212
+ try {
213
+ const existingEntity = await this._datasetStorage.get(datasetId);
214
+ if (!Is.empty(existingEntity) && existingEntity.ownerId !== trustInfo.identity) {
215
+ throw new GeneralError(FederatedCatalogueService.CLASS_NAME, "datasetRemoveNotOwner", {
216
+ datasetId
217
+ });
218
+ }
106
219
  await this._logging?.log({
107
- level: "error",
220
+ level: "info",
108
221
  source: FederatedCatalogueService.CLASS_NAME,
109
222
  ts: Date.now(),
110
- message: "filterIndexCreationFailed",
111
- data: { dataSetId, filterType },
112
- error: BaseError.fromError(error)
223
+ message: "datasetRemove",
224
+ data: { datasetId }
113
225
  });
226
+ await this._datasetStorage.remove(datasetId);
227
+ }
228
+ finally {
229
+ Mutex.unlock(datasetId);
114
230
  }
115
231
  }
116
- await this._datasetStorage.set(datasetEntity);
232
+ catch (error) {
233
+ return transformToCatalogError(error);
234
+ }
117
235
  }
118
236
  /**
119
237
  * Execute a query against the catalogue using registered filter plugins.
120
- * Returns a complete DCAT Catalog object with proper JSON-LD context, metadata, and datasets.
121
- * The filter payload is evaluated by the appropriate filter plugin based on its structure.
122
- * Pagination properties (cursor, limit) and filter type (@type) are extracted from the filter object.
238
+ * Returns a DS Protocol compliant Catalog object with participantId.
239
+ *
240
+ * The root catalog's participantId is the requesting participant (from context).
241
+ * Own datasets (matching requestingParticipantId) go directly in root dataset[].
242
+ * Other participants' datasets are grouped in nested catalog[] entries.
243
+ *
244
+ * For anonymous requests (no context), uses the first publisher found as fallback.
245
+ * Returns CatalogError 404 when no datasets exist, CatalogError 400 for invalid requests.
246
+ *
123
247
  * @param filter The filter criteria containing @type, optional cursor and limit properties.
124
- * @returns Complete ICatalog object with @context, @id, @type, dcat:dataset, and optional cursor.
125
- * @throws NotFoundError if @type is missing or if the filter type is not registered.
248
+ * @param cursor Optional cursor for pagination.
249
+ * @param limit Optional limit for pagination.
250
+ * @param trustPayload Optional payload for trust evaluation, if applicable.
251
+ * @returns Complete IDataspaceProtocolCatalog with @context, @id, @type, participantId, dataset/catalog,
252
+ * or CatalogError if validation fails or an error occurs.
126
253
  */
127
- async query(filter) {
128
- let datasets;
129
- let resultCursor;
130
- const isArray = Is.array(filter);
131
- if (!filter || (isArray && filter.length === 0)) {
132
- const result = await this._datasetStorage.query();
133
- datasets = result.entities.map(entity => datasetEntityToModel(entity));
134
- }
135
- else if (isArray && filter.length > 1) {
136
- throw new GeneralError(FederatedCatalogueService.CLASS_NAME, "multipleFiltersNotSupported");
137
- }
138
- else {
139
- const singleFilter = filter[0];
140
- const cursor = singleFilter?.cursor;
141
- const limit = singleFilter?.limit;
142
- const filterType = singleFilter?.["@type"];
254
+ async query(filter, cursor, limit, trustPayload) {
255
+ try {
256
+ const trustInfo = await TrustHelper.verifyTrust(this._trustComponent, trustPayload, "query");
257
+ let datasets;
258
+ let resultCursor;
259
+ if (!Is.empty(filter) && !Is.array(filter)) {
260
+ throw new GeneralError(FederatedCatalogueService.CLASS_NAME, "filterMustBeArray");
261
+ }
262
+ if (!Is.arrayValue(filter)) {
263
+ const result = await this._datasetStorage.query(undefined, undefined, undefined, cursor, limit);
264
+ datasets = result.entities.map(entity => datasetEntityToModel(entity));
265
+ resultCursor = result.cursor;
266
+ }
267
+ else if (filter.length > 1) {
268
+ throw new GeneralError(FederatedCatalogueService.CLASS_NAME, "multipleFiltersNotSupported");
269
+ }
270
+ else {
271
+ const singleFilter = filter[0];
272
+ const filterType = singleFilter?.["@type"];
273
+ await this._logging?.log({
274
+ level: "info",
275
+ source: FederatedCatalogueService.CLASS_NAME,
276
+ ts: Date.now(),
277
+ message: "catalogQuery",
278
+ data: { filterType: filterType ?? "", cursor: cursor ?? "", limit: limit ?? "" }
279
+ });
280
+ Guards.stringValue(FederatedCatalogueService.CLASS_NAME, "filterType", filterType);
281
+ const selectedFilter = FederatedCatalogueFilterFactory.get(filterType);
282
+ ObjectHelper.propertyDelete(filter, "@type");
283
+ const result = await selectedFilter.query(trustInfo, filter, cursor, limit);
284
+ datasets = result.datasets;
285
+ resultCursor = result.cursor;
286
+ }
143
287
  await this._logging?.log({
144
288
  level: "info",
145
289
  source: FederatedCatalogueService.CLASS_NAME,
146
290
  ts: Date.now(),
147
- message: "catalogQuery",
148
- data: { filterType: filterType ?? "", cursor: cursor ?? "", limit: limit ?? "" }
291
+ message: "catalogQueryComplete",
292
+ data: { resultCount: datasets.length, hasMore: Is.stringValue(resultCursor) }
149
293
  });
150
- Guards.stringValue(FederatedCatalogueService.CLASS_NAME, "filterType", filterType);
151
- const selectedFilter = FederatedCatalogueFilterFactory.get(filterType);
152
- ObjectHelper.propertyDelete(filter, "cursor");
153
- ObjectHelper.propertyDelete(filter, "limit");
154
- ObjectHelper.propertyDelete(filter, "@type");
155
- const result = await selectedFilter.query(filter);
156
- datasets = result.datasets;
157
- resultCursor = result.cursor;
294
+ // Return CatalogError 404 when no datasets exist
295
+ if (!Is.arrayValue(datasets)) {
296
+ throw new NotFoundError(FederatedCatalogueService.CLASS_NAME, "noDatasetsFound");
297
+ }
298
+ let requestingParticipantId = trustInfo.identity;
299
+ // Group datasets by dcterms:publisher (participantId)
300
+ const datasetsByParticipant = new Map();
301
+ for (const dataset of datasets) {
302
+ const publisher = this.extractPublisher(dataset);
303
+ const participantId = publisher ?? "unknown";
304
+ const existing = datasetsByParticipant.get(participantId) ?? [];
305
+ existing.push(dataset);
306
+ datasetsByParticipant.set(participantId, existing);
307
+ }
308
+ const participantIds = [...datasetsByParticipant.keys()];
309
+ // For anonymous requests (no context), use first publisher as fallback
310
+ if (!Is.stringValue(requestingParticipantId)) {
311
+ requestingParticipantId = participantIds[0] ?? "unknown";
312
+ }
313
+ // Separate own datasets from other participants' datasets
314
+ const ownDatasets = datasetsByParticipant.get(requestingParticipantId) ?? [];
315
+ const otherParticipantIds = participantIds.filter(id => id !== requestingParticipantId);
316
+ let catalog;
317
+ if (!Is.arrayValue(otherParticipantIds)) {
318
+ // Only own datasets (or all datasets belong to requesting participant)
319
+ const catalogId = this.generateCatalogId(ownDatasets, requestingParticipantId);
320
+ catalog = {
321
+ "@context": [DataspaceProtocolContexts.Context],
322
+ "@id": catalogId,
323
+ "@type": DataspaceProtocolCatalogTypes.Catalog,
324
+ participantId: requestingParticipantId,
325
+ dataset: ownDatasets
326
+ };
327
+ }
328
+ else {
329
+ // Mixed: own datasets at root level, others in nested catalogs
330
+ const nestedCatalogs = [];
331
+ for (const participantId of otherParticipantIds) {
332
+ const participantDatasets = datasetsByParticipant.get(participantId) ?? [];
333
+ const subCatalogId = this.generateCatalogId(participantDatasets, participantId);
334
+ const subCatalog = {
335
+ "@context": [DataspaceProtocolContexts.Context],
336
+ "@id": subCatalogId,
337
+ "@type": DataspaceProtocolCatalogTypes.Catalog,
338
+ participantId,
339
+ dataset: participantDatasets
340
+ };
341
+ nestedCatalogs.push(subCatalog);
342
+ }
343
+ // Root catalog contains own datasets and nested catalogs for others
344
+ const rootCatalogId = this.generateCatalogId(datasets, requestingParticipantId);
345
+ catalog = {
346
+ "@context": [DataspaceProtocolContexts.Context],
347
+ "@id": rootCatalogId,
348
+ "@type": DataspaceProtocolCatalogTypes.Catalog,
349
+ participantId: requestingParticipantId,
350
+ dataset: ownDatasets,
351
+ catalog: nestedCatalogs
352
+ };
353
+ }
354
+ // Normalize to DS Protocol compliant format
355
+ // This ensures the payload matches exactly what the DS Protocol mandates
356
+ const normalizedCatalog = await DataspaceProtocolHelper.normalize(JsonLdHelper.toNodeObject(catalog));
357
+ const structuredResult = JsonLdHelper.toStructuredObject(normalizedCatalog);
358
+ return {
359
+ result: structuredResult,
360
+ cursor: resultCursor
361
+ };
158
362
  }
159
- await this._logging?.log({
160
- level: "info",
161
- source: FederatedCatalogueService.CLASS_NAME,
162
- ts: Date.now(),
163
- message: "catalogQueryComplete",
164
- data: { resultCount: datasets.length, hasMore: Is.stringValue(resultCursor) }
165
- });
166
- // Generate deterministic catalog ID using canonical hash of dataset IDs
167
- // Sort dataset IDs to ensure consistent ordering
363
+ catch (error) {
364
+ return {
365
+ result: transformToCatalogError(error)
366
+ };
367
+ }
368
+ }
369
+ /**
370
+ * Extract publisher from dataset.
371
+ * Publisher can be a string or an IFoafAgent object with @id.
372
+ * @param dataset The dataset to extract publisher from.
373
+ * @returns The publisher string or undefined if not found.
374
+ * @internal
375
+ */
376
+ extractPublisher(dataset) {
377
+ const publisher = dataset["dcterms:publisher"];
378
+ // Handle case where publisher is an object with @id (IFoafAgent)
379
+ if (Is.object(publisher)) {
380
+ const publisherId = publisher["@id"];
381
+ return Is.stringValue(publisherId) ? publisherId : undefined;
382
+ }
383
+ return Is.stringValue(publisher) ? publisher : undefined;
384
+ }
385
+ /**
386
+ * Bake the publishing organization token into each distribution's accessService URL.
387
+ * @param entity The dataset entity to modify in place.
388
+ * @param organizationId The publishing organization id to bake.
389
+ * @internal
390
+ */
391
+ async bakeOrganizationIntoDistributions(entity, organizationId) {
392
+ // Storage uses the prefixed JSON-LD key "dcat:accessService" (not the unprefixed
393
+ // "accessService" that appears in compacted query responses).
394
+ const distributions = ArrayHelper.fromObjectOrArray(entity["dcat:distribution"]) ?? [];
395
+ for (const dist of distributions) {
396
+ const accessService = dist?.["dcat:accessService"];
397
+ if (Is.stringValue(accessService)) {
398
+ dist["dcat:accessService"] = HttpUrlHelper.addQueryStringParam(accessService, ContextIdKeys.Organization, organizationId);
399
+ }
400
+ else if (Is.objectValue(accessService)) {
401
+ // DCAT object form: a dcat:DataService with an endpointURL.
402
+ const dataService = accessService;
403
+ if (Is.stringValue(dataService["dcat:endpointURL"])) {
404
+ dataService["dcat:endpointURL"] = HttpUrlHelper.addQueryStringParam(dataService["dcat:endpointURL"], ContextIdKeys.Organization, organizationId);
405
+ }
406
+ }
407
+ }
408
+ }
409
+ /**
410
+ * Generate a deterministic catalog ID using canonical hash.
411
+ * @param datasets The datasets to include in the hash.
412
+ * @param participantId The participant ID to include in the hash.
413
+ * @returns A URN-formatted catalog ID.
414
+ * @internal
415
+ */
416
+ generateCatalogId(datasets, participantId) {
168
417
  const datasetIds = datasets
169
418
  .map(d => d["@id"])
170
419
  .filter(id => Is.stringValue(id))
171
420
  .sort();
172
- // Create deterministic representation for hashing
173
- const canonicalContent = JSON.stringify({
174
- "@type": DcatClasses.Catalog,
421
+ const canonicalContent = JsonHelper.canonicalize({
422
+ "@type": DataspaceProtocolCatalogTypes.Catalog,
423
+ participantId,
175
424
  datasets: datasetIds
176
425
  });
177
426
  const canonicalBytes = Converter.utf8ToBytes(canonicalContent);
178
427
  const catalogHash = Converter.bytesToHex(Blake2b.sum256(canonicalBytes));
179
- const catalogId = `urn:x-catalog:${catalogHash}`;
180
- // Return complete catalog with deterministic ID
181
- const catalog = {
182
- "@context": [
183
- DataspaceProtocolContexts.ContextRoot,
184
- {
185
- dcat: DcatContexts.ContextRoot,
186
- dcterms: DublinCoreContexts.ContextTerms,
187
- cursor: `${FederatedCatalogueContexts.ContextRoot}cursor`
188
- }
189
- ],
190
- "@id": catalogId,
191
- "@type": DcatClasses.Catalog,
192
- "dcat:dataset": datasets
193
- };
194
- if (resultCursor) {
195
- catalog.cursor = resultCursor;
196
- }
197
- // Apply JSON-LD compaction to ensure proper context handling
198
- const compactedCatalog = await JsonLdProcessor.compact(catalog, catalog["@context"]);
199
- return compactedCatalog;
200
- }
201
- /**
202
- * Remove a dataset from the catalogue by its unique identifier.
203
- * Indexes are automatically removed as they are stored with the dataset.
204
- * @param dataSetId The unique identifier of the dataset to remove.
205
- */
206
- async remove(dataSetId) {
207
- Guards.stringValue(FederatedCatalogueService.CLASS_NAME, "dataSetId", dataSetId);
208
- await this._logging?.log({
209
- level: "info",
210
- source: FederatedCatalogueService.CLASS_NAME,
211
- ts: Date.now(),
212
- message: "datasetRemove",
213
- data: { dataSetId }
214
- });
215
- await this._datasetStorage.remove(dataSetId);
428
+ return `urn:x-catalog:${catalogHash}`;
216
429
  }
217
430
  }
218
431
  //# sourceMappingURL=federatedCatalogueService.js.map