jskos-server 2.4.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.
Files changed (112) hide show
  1. package/.dockerignore +20 -0
  2. package/.editorconfig +9 -0
  3. package/.github/workflows/docker.yml +59 -0
  4. package/.github/workflows/gh-pages.yml +23 -0
  5. package/.github/workflows/gh-release.yml +19 -0
  6. package/.github/workflows/test.yml +39 -0
  7. package/.husky/pre-commit +1 -0
  8. package/CHANGELOG.md +18 -0
  9. package/LICENSE +21 -0
  10. package/README.md +2710 -0
  11. package/bin/extra.js +81 -0
  12. package/bin/import.js +438 -0
  13. package/bin/mongodb.js +21 -0
  14. package/bin/reset.js +257 -0
  15. package/bin/upgrade.js +34 -0
  16. package/config/config.default.json +88 -0
  17. package/config/config.schema.json +877 -0
  18. package/config/config.test.json +107 -0
  19. package/config/index.js +77 -0
  20. package/config/setup.js +212 -0
  21. package/depdendencies.png +0 -0
  22. package/docker/.env +1 -0
  23. package/docker/Dockerfile +20 -0
  24. package/docker/README.md +175 -0
  25. package/docker/docker-compose.yml +29 -0
  26. package/docker/docker-entrypoint.sh +8 -0
  27. package/docker/mongo-initdb.d/mongo_setup.sh +22 -0
  28. package/ecosystem.example.json +7 -0
  29. package/errors/index.js +94 -0
  30. package/eslint.config.js +17 -0
  31. package/index.js +10 -0
  32. package/models/annotations.js +13 -0
  33. package/models/concepts.js +12 -0
  34. package/models/concordances.js +12 -0
  35. package/models/index.js +33 -0
  36. package/models/mappings.js +20 -0
  37. package/models/meta.js +21 -0
  38. package/models/registries.js +12 -0
  39. package/models/schemes.js +12 -0
  40. package/package.json +91 -0
  41. package/routes/annotations.js +83 -0
  42. package/routes/common.js +86 -0
  43. package/routes/concepts.js +64 -0
  44. package/routes/concordances.js +86 -0
  45. package/routes/data.js +19 -0
  46. package/routes/mappings.js +108 -0
  47. package/routes/registries.js +24 -0
  48. package/routes/schemes.js +72 -0
  49. package/routes/validate.js +37 -0
  50. package/server.js +190 -0
  51. package/services/abstract.js +328 -0
  52. package/services/annotations.js +237 -0
  53. package/services/concepts.js +459 -0
  54. package/services/concordances.js +264 -0
  55. package/services/data.js +30 -0
  56. package/services/index.js +34 -0
  57. package/services/mappings.js +978 -0
  58. package/services/registries.js +319 -0
  59. package/services/schemes.js +318 -0
  60. package/services/validate.js +39 -0
  61. package/status.schema.json +145 -0
  62. package/test/abstract-service.js +36 -0
  63. package/test/annotations/annotation.json +13 -0
  64. package/test/api.js +2481 -0
  65. package/test/chai.js +14 -0
  66. package/test/changes.js +179 -0
  67. package/test/concepts/conceptNoFileEnding +4 -0
  68. package/test/concepts/concepts-ddc-6-60-61-62.json +123 -0
  69. package/test/concordances/concordances.ndjson +2 -0
  70. package/test/config.js +26 -0
  71. package/test/configs/complex-config.json +90 -0
  72. package/test/configs/empty-object.json +1 -0
  73. package/test/configs/fail-array.json +1 -0
  74. package/test/configs/fail-empty.json +0 -0
  75. package/test/configs/fail-mapping-only-props1.json +5 -0
  76. package/test/configs/fail-mapping-only-props2.json +5 -0
  77. package/test/configs/fail-mapping-only-props3.json +5 -0
  78. package/test/configs/fail-mapping-only-props4.json +5 -0
  79. package/test/configs/fail-nonexisting-prop.json +3 -0
  80. package/test/configs/fail-port-string.json +3 -0
  81. package/test/configs/fail-registry-types.json +16 -0
  82. package/test/configs/registry-types.json +16 -0
  83. package/test/data-write.js +784 -0
  84. package/test/eslint.js +22 -0
  85. package/test/import-reset.js +287 -0
  86. package/test/infer-mappings.js +340 -0
  87. package/test/ipcheck.js +287 -0
  88. package/test/mappings/README.md +1 -0
  89. package/test/mappings/ddc-gnd-1.mapping.json +33 -0
  90. package/test/mappings/ddc-gnd-2.mapping.json +67 -0
  91. package/test/mappings/mapping-ddc-gnd-noScheme.json +145 -0
  92. package/test/mappings/mapping-ddc-gnd.json +175 -0
  93. package/test/mappings/mappings-ddc.json +214 -0
  94. package/test/registries/registries.ndjson +2 -0
  95. package/test/services.js +557 -0
  96. package/test/terminologies/terminologies.json +94 -0
  97. package/test/test-utils.js +182 -0
  98. package/test/utils.js +425 -0
  99. package/test/validate.js +226 -0
  100. package/utils/adjust.js +206 -0
  101. package/utils/auth.js +154 -0
  102. package/utils/changes.js +88 -0
  103. package/utils/db.js +106 -0
  104. package/utils/ipcheck.js +76 -0
  105. package/utils/middleware.js +636 -0
  106. package/utils/searchHelper.js +153 -0
  107. package/utils/status.js +77 -0
  108. package/utils/users.js +7 -0
  109. package/utils/utils.js +114 -0
  110. package/utils/uuid.js +6 -0
  111. package/utils/version.js +324 -0
  112. package/views/base.ejs +172 -0
@@ -0,0 +1,459 @@
1
+ import _ from "lodash"
2
+ import jskos from "jskos-tools"
3
+ import { validate } from "jskos-validate"
4
+ import { bulkOperationForEntities, queryToAggregation } from "../utils/utils.js"
5
+ import { addKeywords } from "../utils/searchHelper.js"
6
+ import { Concept } from "../models/concepts.js"
7
+ import { SchemeService } from "../services/schemes.js"
8
+ import { MalformedBodyError, MalformedRequestError, EntityNotFoundError, InvalidBodyError, DatabaseAccessError } from "../errors/index.js"
9
+
10
+ import { AbstractService } from "./abstract.js"
11
+
12
+ export class ConceptService extends AbstractService {
13
+
14
+ constructor(config) {
15
+ super(config)
16
+ this.schemeService = new SchemeService(config)
17
+ this.model = Concept
18
+ }
19
+
20
+ retrieveItems(query, $skip, $limit, narrower = true) {
21
+ const pipeline = queryToAggregation(query)
22
+ if (narrower) {
23
+ pipeline.push({
24
+ $lookup: {
25
+ from: Concept.collection.name,
26
+ localField: "uri",
27
+ foreignField: "broader.uri",
28
+ as: "narrower",
29
+ },
30
+ })
31
+ pipeline.push({
32
+ $addFields: {
33
+ narrower: {
34
+ $reduce: {
35
+ input: "$narrower",
36
+ initialValue: [],
37
+ in: [null],
38
+ },
39
+ },
40
+ },
41
+ })
42
+ }
43
+ if (_.isNumber($skip)) {
44
+ pipeline.push({ $skip })
45
+ }
46
+ if (_.isNumber($limit)) {
47
+ pipeline.push({ $limit })
48
+ }
49
+ return Concept.aggregate(pipeline)
50
+ }
51
+
52
+ /**
53
+ * Return a Promise with an array of concept data.
54
+ */
55
+ async getTop(query) {
56
+ let criteria
57
+ if (query.uri) {
58
+ let uris
59
+ // Get scheme from database
60
+ let scheme = await this.schemeService.getScheme(query.uri)
61
+ if (scheme) {
62
+ uris = [scheme.uri].concat(scheme.identifier || [])
63
+ } else {
64
+ uris = [query.uri]
65
+ }
66
+ criteria = { $or: uris.map(uri => ({ "topConceptOf.uri": uri })) }
67
+ } else {
68
+ // Search for all top concepts in all vocabularies
69
+ criteria = { "topConceptOf.uri": { $type: 2 } }
70
+ }
71
+ const concepts = await this.retrieveItems(criteria, query.offset, query.limit)
72
+ concepts.totalCount = await this._count(Concept, [{ $match: criteria }])
73
+ return concepts
74
+ }
75
+
76
+ /**
77
+ * Return a Promise with an array of concepts.
78
+ */
79
+ async queryItems(query) {
80
+ if (!_.intersection(Object.keys(query), ["uri", "notation", "voc", "near"]).length) {
81
+ return []
82
+ }
83
+ const criteria = []
84
+ const mongoQuery = { $and: criteria }
85
+ const uris = query.uri ? query.uri.split("|") : []
86
+ const notations = query.notation ? query.notation.split("|") : []
87
+ if (uris.length || notations.length) {
88
+ criteria.push({
89
+ $or: [].concat(uris.map(uri => ({ uri })), notations.map(notation => ({ notation }))),
90
+ })
91
+ }
92
+ if (query.voc) {
93
+ let uris
94
+ // Get scheme from database
95
+ let scheme = await this.schemeService.getScheme(query.voc)
96
+ if (scheme) {
97
+ uris = [scheme.uri].concat(scheme.identifier || [])
98
+ } else {
99
+ uris = [query.voc]
100
+ }
101
+ criteria.push({ $or: uris.map(uri => ({ "inScheme.uri": uri })) })
102
+ }
103
+ if (query.near) {
104
+ const [latitude, longitude] = query.near.split(",").map(parseFloat)
105
+ // distance is given in km, but MongoDB uses meters
106
+ const distance = (query.distance || 1) * 1000
107
+ if (!_.isFinite(latitude) || !_.isFinite(longitude) || !(latitude >= -90 && latitude <= 90) || !(longitude >= -180 && longitude <= 180)) {
108
+ throw new MalformedRequestError(`Parameter \`near\` (${query.near}) is malformed. The correct format is "latitude,longitude" with latitude between -90 and 90 and longitude between -180 and 180.`)
109
+ }
110
+ if (!distance) {
111
+ throw new MalformedRequestError(`Parameter \`distance\` (${query.distance}) is malformed. Please give a number in km (default: 1).`)
112
+ }
113
+ mongoQuery.location = {
114
+ $nearSphere: {
115
+ $geometry: {
116
+ type: "Point",
117
+ coordinates: [longitude, latitude],
118
+ },
119
+ $maxDistance: distance,
120
+ },
121
+ }
122
+ }
123
+ if (query.download) {
124
+ return this.retrieveItems(mongoQuery, null, null, false).cursor()
125
+ }
126
+ const concepts = await this.retrieveItems(mongoQuery, query.offset, query.limit)
127
+ concepts.totalCount = await this._count(Concept, queryToAggregation(mongoQuery))
128
+ return concepts
129
+ }
130
+
131
+ /**
132
+ * Return a Promise with an array of concept data.
133
+ */
134
+ async getNarrower(query) {
135
+ if (!query.uri) {
136
+ return []
137
+ }
138
+ return await this.retrieveItems({ broader: { $elemMatch: { uri: query.uri } } })
139
+ }
140
+
141
+ /**
142
+ * Return a Promise with an array of concept data.
143
+ */
144
+ async getAncestors(query, root = true) {
145
+ if (!query.uri) {
146
+ return []
147
+ }
148
+ const uri = query.uri
149
+ // First retrieve the concept object from database
150
+ const concept = await this.getItem(uri)
151
+ if (!concept) {
152
+ return []
153
+ }
154
+ if (concept.broader && concept.broader.length) {
155
+ // Load next parent
156
+ let parentUri = concept.broader[0].uri
157
+ // Temporary fix for self-referencing broader
158
+ parentUri = parentUri == uri ? (concept.broader[1] && concept.broader[1].uri) : parentUri
159
+ if (!parentUri) {
160
+ if (root) {
161
+ return []
162
+ } else {
163
+ return [concept]
164
+ }
165
+ }
166
+ const ancestors = await this.getAncestors({ uri: parentUri }, false)
167
+ if (root) {
168
+ return ancestors
169
+ } else {
170
+ return [concept].concat(ancestors)
171
+ }
172
+ } else if (!root) {
173
+ return [concept]
174
+ } else {
175
+ return []
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Return a Promise with an array of suggestions in JSKOS format.
181
+ */
182
+ async search(query) {
183
+ let search = query.query || query.search || ""
184
+ let results = await this.searchItems({ search, voc: query.voc })
185
+ const { limit, offset } = this._getLimitAndOffset(query)
186
+ const searchResults = results.slice(offset, offset + limit)
187
+ searchResults.totalCount = results.length
188
+ return searchResults
189
+ }
190
+
191
+ // Write endpoints start here
192
+
193
+ async createItem({ bodyStream, bulk = false, setApi = false, bulkReplace = true, scheme }) {
194
+ if (!bodyStream) {
195
+ throw new MalformedBodyError()
196
+ }
197
+
198
+ let isMultiple = true
199
+ bodyStream.on("isSingleObject", () => {
200
+ isMultiple = false
201
+ })
202
+
203
+ let response, preparation
204
+
205
+ if (bulk) {
206
+ preparation = await new Promise((resolve) => {
207
+ const preparation = {
208
+ concepts: [],
209
+ schemeUrisToAdjust: [],
210
+ errors: [],
211
+ }
212
+ let current = []
213
+ const saveObjects = async (objects) => {
214
+ const { concepts, errors, schemeUrisToAdjust } = await this.prepareAndCheckConcepts(objects, { scheme })
215
+ concepts.length && await Concept.bulkWrite(bulkOperationForEntities({ entities: concepts, replace: bulkReplace }))
216
+ preparation.concepts = preparation.concepts.concat(concepts.map(c => ({ uri: c.uri })))
217
+ preparation.errors = preparation.errors.concat(errors.map(c => ({ uri: c.uri })))
218
+ preparation.schemeUrisToAdjust = _.uniq(preparation.schemeUrisToAdjust.concat(schemeUrisToAdjust))
219
+ }
220
+ const promises = []
221
+ bodyStream.on("data", (concept) => {
222
+ current.push(concept)
223
+ if (current.length % 5000 == 0) {
224
+ promises.push(saveObjects(current))
225
+ current = []
226
+ }
227
+ })
228
+ bodyStream.on("end", async () => {
229
+ promises.push(saveObjects(current))
230
+ await Promise.all(promises)
231
+ preparation.errors.length && this.warn(`Warning on bulk import of concepts: ${preparation.errors.length} concepts were not imported due to validation errors.`)
232
+ resolve(preparation)
233
+ })
234
+ })
235
+ response = preparation.concepts
236
+ } else {
237
+ // Fully assemble body for non-bulk operations
238
+ let { items } = await this._readBodyStream(bodyStream)
239
+ // Prepare
240
+ preparation = await this.prepareAndCheckConcepts(items, { scheme })
241
+ items = preparation.concepts
242
+ if (preparation.errors.length) {
243
+ throw preparation.errors[0]
244
+ }
245
+ // Insert concepts
246
+ response = await Concept.insertMany(items, { lean: true })
247
+ }
248
+
249
+ preparation.setApi = setApi
250
+ await this.postAdjustmentsForItems(preparation)
251
+
252
+ return isMultiple ? response : response[0]
253
+ }
254
+
255
+ async updateItem({ body, existing }) {
256
+ if (!body) {
257
+ throw new MalformedBodyError()
258
+ }
259
+
260
+ if (!_.isObject(body)) {
261
+ throw new MalformedBodyError()
262
+ }
263
+ let concept = body
264
+
265
+ // Prepare
266
+ const preparation = await this.prepareAndCheckConcepts([concept])
267
+
268
+ // Throw error if necessary
269
+ if (preparation.errors.length) {
270
+ throw preparation.errors[0]
271
+ }
272
+ concept = preparation.concepts[0]
273
+
274
+ // Override _id, uri, and created properties
275
+ concept._id = existing._id
276
+ concept.uri = existing.uri
277
+ concept.created = existing.created
278
+
279
+ // Write concept to database
280
+ const result = await Concept.replaceOne({ _id: existing._id }, concept)
281
+ if (!result.acknowledged) {
282
+ throw new DatabaseAccessError()
283
+ }
284
+ if (!result.matchedCount) {
285
+ throw new EntityNotFoundError()
286
+ }
287
+
288
+ // ? Can we return the request without waiting for this step?
289
+ await this.postAdjustmentsForItems(preparation)
290
+
291
+ return concept
292
+ }
293
+
294
+ async deleteItem({ uri, existing, setApi = false }) {
295
+ if (!uri) {
296
+ throw new MalformedRequestError()
297
+ }
298
+
299
+ super.deleteItem({ existing })
300
+
301
+ await this.postAdjustmentsForItems({
302
+ // Adjust scheme in case it was its last concept
303
+ schemeUrisToAdjust: [existing?.inScheme?.[0]?.uri],
304
+ conceptUrisWithNarrower: [],
305
+ setApi,
306
+ })
307
+ }
308
+
309
+ async deleteConceptsFromScheme({ uri, scheme, setApi = false }) {
310
+ if (!uri && !scheme) {
311
+ throw new MalformedRequestError()
312
+ }
313
+ if (uri && !scheme) {
314
+ scheme = await this.schemeService.getScheme(uri)
315
+ }
316
+ if (!scheme) {
317
+ throw new EntityNotFoundError(`Could not find scheme with URI ${uri} to delete concepts from.`)
318
+ }
319
+
320
+ const result = await Concept.deleteMany({ "inScheme.uri": { $in: [scheme.uri].concat(scheme.identifier || []) } })
321
+ if (!result) {
322
+ throw new DatabaseAccessError()
323
+ }
324
+ if (!result.deletedCount) {
325
+ throw new EntityNotFoundError("No concepts found to delete.")
326
+ }
327
+ await this.postAdjustmentsForItems({
328
+ schemeUrisToAdjust: [uri],
329
+ conceptUrisWithNarrower: [],
330
+ setApi,
331
+ })
332
+ }
333
+
334
+ /**
335
+ * Prepares and checks a list of concepts before inserting/updating (see `prepareAndCheckConcept`).
336
+ *
337
+ * @param {Object} allConcept concept objects
338
+ * @returns {Object} preparation object with properties `concepts`, `errors`, `schemeUrisToAdjust`, and `conceptUrisWithNarrower`; needs to be provided to `postAdjustmentsForItems`
339
+ */
340
+ async prepareAndCheckConcepts(allConcepts, { scheme } = {}) {
341
+ const getSchemeUri = c => c?.inScheme?.[0]?.uri || c?.topConceptOf?.[0]?.uri
342
+ const schemeUrisToAdjust = []
343
+ const concepts = []
344
+ const errors = []
345
+ // Set inScheme for concepts when `scheme` option is given
346
+ if (scheme) {
347
+ allConcepts.forEach(concept => {
348
+ if (!getSchemeUri(concept)) {
349
+ concept.inScheme = [{ uri: scheme }]
350
+ }
351
+ })
352
+ }
353
+ // Load all schemes for concepts
354
+ const schemes = await this.schemeService.queryItems({
355
+ uri: allConcepts.map(c => getSchemeUri(c)).filter(Boolean).join("|"),
356
+ })
357
+ for (let concept of allConcepts) {
358
+ try {
359
+ await this.prepareAndCheckConcept(concept, schemes)
360
+ let scheme = concept?.inScheme?.[0]?.uri
361
+ if (scheme && !schemeUrisToAdjust.includes(scheme)) {
362
+ schemeUrisToAdjust.push(scheme)
363
+ }
364
+ concepts.push(concept)
365
+ } catch (error) {
366
+ errors.push(error)
367
+ }
368
+ }
369
+ return {
370
+ concepts,
371
+ errors,
372
+ schemeUrisToAdjust,
373
+ }
374
+ }
375
+
376
+ /**
377
+ * Prepares and checks a concept before inserting/updating:
378
+ * - copies `topConceptOf` to `inScheme` if necessary
379
+ * - validates object, throws error if it doesn't
380
+ * - makes sure that it has a valid scheme, throws error if it doesn't
381
+ * - adjust scheme URI if necessary
382
+ * - adds certain keyword properties necessary for text indexes
383
+ *
384
+ * @param {Object} concept concept object
385
+ * @param {[Object]} schemes array of schemes
386
+ */
387
+ async prepareAndCheckConcept(concept, schemes) {
388
+ concept._id = concept.uri
389
+ // Add "inScheme" for all top concepts
390
+ if (!concept.inScheme && concept.topConceptOf) {
391
+ concept.inScheme = concept.topConceptOf
392
+ }
393
+ // Remove `narrower` and `ancestors` properties => we're only using `broader` to build the concept hierarchy
394
+ delete concept.narrower
395
+ delete concept.ancestors
396
+ // Validate concept
397
+ if (!validate.concept(concept) || !concept.uri) {
398
+ throw new InvalidBodyError()
399
+ }
400
+ // Check concept scheme
401
+ const inScheme = concept.inScheme?.[0]
402
+ // Load scheme from database if necessary
403
+ if (!schemes || !schemes.length) {
404
+ schemes = await this.schemeService.queryItems({ uri: inScheme.uri })
405
+ }
406
+ const scheme = schemes.find(s => jskos.compare(s, inScheme))
407
+ if (!scheme) {
408
+ // Either no scheme at all or not found in database
409
+ let message = "Error when adding concept to database: "
410
+ if (inScheme) {
411
+ message += `Concept scheme with URI ${inScheme.uri} is not supported.`
412
+ } else {
413
+ message += "Concept has no concept scheme."
414
+ }
415
+ throw new MalformedRequestError(message)
416
+ }
417
+ // Adjust URIs of schemes
418
+ concept.inScheme[0].uri = scheme.uri
419
+ if (concept.topConceptOf && concept.topConceptOf.length) {
420
+ concept.topConceptOf[0].uri = scheme.uri
421
+ }
422
+ // Add index keywords
423
+ addKeywords(concept)
424
+ }
425
+
426
+ async postAdjustmentsForItems(preparation) {
427
+ // runs `postAdjustmentsForItems` for relevant schemes in `preparation.schemeUrisToAdjust`
428
+ // adds `narrower: [null]` for concepts in `preparation.conceptUrisWithNarrower`
429
+ await this.schemeService.postAdjustmentsForItems(preparation.schemeUrisToAdjust.map(uri => ({ uri })), { setApi: preparation.setApi })
430
+ }
431
+
432
+ async createIndexes() {
433
+ await this._createIndexes([
434
+ [{ "broader.uri": 1 }, {}],
435
+ [{ "topConceptOf.uri": 1 }, {}],
436
+ [{ "inScheme.uri": 1 }, {}],
437
+ [{ uri: 1 }, {}],
438
+ [{ notation: 1 }, {}],
439
+ [{ identifier: 1 }, {}],
440
+ [{ _keywordsLabels: 1 }, {}],
441
+ [{ location: "2dsphere" }, {}],
442
+ [
443
+ {
444
+ _keywordsNotation: "text",
445
+ _keywordsLabels: "text",
446
+ _keywordsOther: "text",
447
+ },
448
+ {
449
+ name: "text",
450
+ default_language: "german",
451
+ weights: {
452
+ _keywordsNotation: 10,
453
+ _keywordsLabels: 6,
454
+ _keywordsOther: 3,
455
+ },
456
+ },
457
+ ]])
458
+ }
459
+ }