@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.
- package/README.md +1 -1
- package/dist/es/entities/dataset.js +12 -4
- package/dist/es/entities/dataset.js.map +1 -1
- package/dist/es/federatedCatalogueRoutes.js +241 -53
- package/dist/es/federatedCatalogueRoutes.js.map +1 -1
- package/dist/es/index.js +2 -0
- package/dist/es/index.js.map +1 -1
- package/dist/es/models/IFederatedCatalogueServiceConfig.js +4 -0
- package/dist/es/models/IFederatedCatalogueServiceConfig.js.map +1 -0
- package/dist/es/models/IFederatedCatalogueServiceConstructorOptions.js.map +1 -1
- package/dist/es/services/federatedCatalogueService.js +344 -131
- package/dist/es/services/federatedCatalogueService.js.map +1 -1
- package/dist/es/utils/catalogErrorUtils.js +49 -0
- package/dist/es/utils/catalogErrorUtils.js.map +1 -0
- package/dist/es/utils/datasetConverters.js +23 -10
- package/dist/es/utils/datasetConverters.js.map +1 -1
- package/dist/types/entities/dataset.d.ts +37 -33
- package/dist/types/index.d.ts +2 -0
- package/dist/types/models/IFederatedCatalogueServiceConfig.d.ts +5 -0
- package/dist/types/models/IFederatedCatalogueServiceConstructorOptions.d.ts +14 -4
- package/dist/types/services/federatedCatalogueService.d.ts +37 -21
- package/dist/types/utils/catalogErrorUtils.d.ts +15 -0
- package/dist/types/utils/datasetConverters.d.ts +10 -10
- package/docs/changelog.md +271 -3
- package/docs/examples.md +126 -1
- package/docs/open-api/spec.json +256 -2513
- package/docs/reference/classes/Dataset.md +70 -62
- package/docs/reference/classes/FederatedCatalogueService.md +77 -40
- package/docs/reference/functions/datasetEntityToModel.md +7 -7
- package/docs/reference/functions/datasetModelToEntity.md +11 -6
- package/docs/reference/functions/transformErrorToStatusCode.md +19 -0
- package/docs/reference/functions/transformToCatalogError.md +20 -0
- package/docs/reference/index.md +3 -0
- package/docs/reference/interfaces/IFederatedCatalogueServiceConfig.md +3 -0
- package/docs/reference/interfaces/IFederatedCatalogueServiceConstructorOptions.md +33 -5
- package/locales/en.json +12 -6
- 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 {
|
|
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 {
|
|
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 {
|
|
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?.
|
|
38
|
-
this._datasetStorage = EntityStorageConnectorFactory.get(options?.
|
|
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
|
|
55
|
-
* @
|
|
56
|
-
* @
|
|
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(
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
*
|
|
76
|
-
* @param
|
|
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(
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
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: "
|
|
102
|
-
data: {
|
|
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
|
-
|
|
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: "
|
|
220
|
+
level: "info",
|
|
108
221
|
source: FederatedCatalogueService.CLASS_NAME,
|
|
109
222
|
ts: Date.now(),
|
|
110
|
-
message: "
|
|
111
|
-
data: {
|
|
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
|
-
|
|
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
|
|
121
|
-
*
|
|
122
|
-
*
|
|
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
|
-
* @
|
|
125
|
-
* @
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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: "
|
|
148
|
-
data: {
|
|
291
|
+
message: "catalogQueryComplete",
|
|
292
|
+
data: { resultCount: datasets.length, hasMore: Is.stringValue(resultCursor) }
|
|
149
293
|
});
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|