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,636 @@
1
+ import config from "../config/index.js"
2
+ import _ from "lodash"
3
+ import jskos from "jskos-tools"
4
+ import { DuplicateEntityError, EntityNotFoundError, CreatorDoesNotMatchError, DatabaseInconsistencyError, InvalidBodyError } from "../errors/index.js"
5
+
6
+ import { Transform, Readable } from "node:stream"
7
+ import JSONStream from "JSONStream"
8
+ import * as anystream from "json-anystream"
9
+ import express from "express"
10
+
11
+ import { cleanJSON } from "./utils.js"
12
+ import { getUrisOfUser } from "./users.js"
13
+ import { createServices } from "../services/index.js"
14
+ import { createAdjuster } from "./adjust.js"
15
+
16
+ const services = createServices(config)
17
+ const adjust = createAdjuster(config, services)
18
+
19
+ /**
20
+ * Wraps an async middleware function that returns data in the Promise.
21
+ * The result of the Promise will be written into req.data for access by following middlewaren.
22
+ * A rejected Promise will be caught and relayed to the Express error handling.
23
+ *
24
+ * adjusted from: https://thecodebarbarian.com/80-20-guide-to-express-error-handling
25
+ */
26
+ const wrapAsync = (fn) => {
27
+ return (req, res, next) => {
28
+ fn(req, res, next).then(data => {
29
+ // On success, save the result of the Promise in req.data.
30
+ req.data = data
31
+ next()
32
+ }).catch(error => {
33
+ // Catch and change certain errors
34
+ if (error.code === 11000) {
35
+ const _id = _.get(error, "keyValue._id") || _.get(error, "writeErrors[0].err.op._id")
36
+ error = new DuplicateEntityError(null, `${_id} (${req.type})`)
37
+ }
38
+ // Pass error to the next error middleware.
39
+ next(error)
40
+ })
41
+ }
42
+ }
43
+
44
+ // Middleware wrapper that calls the middleware depending on req.query.download
45
+ const wrapDownload = (fn, isDownload = true) => {
46
+ return (req, res, next) => {
47
+ if (!!req.query.download === isDownload) {
48
+ fn(req, res, next)
49
+ } else {
50
+ next()
51
+ }
52
+ }
53
+ }
54
+
55
+ const buildUrlForLinkHeader = ({ query, rel, req }) => {
56
+ let url = config.baseUrl.substring(0, config.baseUrl.length - 1) + req.path
57
+ if (!query && req) {
58
+ query = req.query
59
+ }
60
+ let index = 0
61
+ _.forOwn(_.omit(query, ["bulk"]), (value, key) => {
62
+ url += `${(index == 0 ? "?" : "&")}${key}=${encodeURIComponent(value)}`
63
+ index += 1
64
+ })
65
+ return `<${url}>; rel="${rel}"`
66
+ }
67
+
68
+ /**
69
+ * Returns `true` if the creator of `object` matches `user`, `false` if not.
70
+ * `object.creator` can be
71
+ * - an array of objects
72
+ * - an object
73
+ * - a string
74
+ * The object for a creator will be checked for properties `uri` (e.g. JSKOS mapping) and `id` (e.g. annotations).
75
+ *
76
+ * If config.auth.allowCrossUserEditing is enabled, this returns true as long as a user and object are given.
77
+ *
78
+ * @param {object} options.req the request object (that includes req.user, req.crossUser, and req.auth)
79
+ * @param {object} options.object any object that has the property `creator`
80
+ * @param {boolean} options.withContributors allow contributors to be matched (for object with superordinated object)
81
+ */
82
+ const matchesCreator = ({ req = {}, object, withContributors = false }) => {
83
+ const { user, crossUser, auth } = req
84
+ if (!auth) {
85
+ return true
86
+ }
87
+ if (!object || !user) {
88
+ return false
89
+ }
90
+ const userUris = getUrisOfUser(user)
91
+ if (crossUser === true || _.intersection(crossUser || [], userUris).length) {
92
+ return true
93
+ }
94
+ // Support arrays, objects, and strings as creators
95
+ let creators = Array.isArray(object.creator) ? object.creator : (_.isObject(object.creator) ? [object.creator] : [{ uri: object.creator }])
96
+ // Also check contributors if requested
97
+ let contributors = withContributors ? (object.contributor || []) : []
98
+ for (let creator of creators.concat(contributors)) {
99
+ if (userUris.includes(creator.uri) || userUris.includes(creator.id)) {
100
+ return true
101
+ }
102
+ }
103
+ return false
104
+ }
105
+
106
+ /**
107
+ * Middleware that adds default headers.
108
+ */
109
+ const addDefaultHeaders = (req, res, next) => {
110
+ if (req.headers.origin) {
111
+ // Allow all origins by returning the request origin in the header
112
+ res.setHeader("Access-Control-Allow-Origin", req.headers.origin)
113
+ } else {
114
+ // Fallback to * if there is no origin in header
115
+ res.setHeader("Access-Control-Allow-Origin", "*")
116
+ }
117
+ res.setHeader("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization")
118
+ res.setHeader("Access-Control-Allow-Methods", "GET,PUT,POST,PATCH,DELETE")
119
+ res.setHeader("Access-Control-Expose-Headers", "X-Total-Count, Link")
120
+ res.setHeader("Content-Type", "application/json; charset=utf-8")
121
+ // Deprecation headers for /narrower, /ancestors, /search, and /suggest
122
+ // TODO for 3.0: Remove these headers
123
+ if (["/narrower", "/ancestors", "/search", "/suggest"].includes(req.path)) {
124
+ res.setHeader("Deprecation", true)
125
+ const links = []
126
+ links.push(buildUrlForLinkHeader({ req, rel: "alternate" }))
127
+ links[0] = links[0].replace(req.path, `/concepts${req.path}`)
128
+ links.push("<https://github.com/gbv/jskos-server/releases/tag/v2.0.0>; rel=\"deprecation\"")
129
+ res.set("Link", links.join(","))
130
+ }
131
+ next()
132
+ }
133
+
134
+ /**
135
+ * Middleware that adds default properties:
136
+ *
137
+ * - If req.query exists, make sure req.query.limit and req.query.offset are set as numbers and make req.bulk a Boolean.
138
+ * - If possible, set req.type depending on the endpoint (one of concepts, schemes, mappings, annotations, suggest).
139
+ */
140
+ const addMiddlewareProperties = (req, res, next) => {
141
+
142
+ if (req.query) {
143
+ const query = { ...req.query }
144
+
145
+ // Limit for pagination
146
+ const defaultLimit = 100
147
+ query.limit = parseInt(req.query.limit)
148
+ if (isNaN(query.limit) || req.query.limit <= 0) {
149
+ query.limit = defaultLimit
150
+ }
151
+ // Offset for pagination
152
+ const defaultOffset = 0
153
+ query.offset = parseInt(req.query.offset)
154
+ if (isNaN(query.offset) || req.query.offset < 0) {
155
+ query.offset = defaultOffset
156
+ }
157
+ // Bulk option for POST endpoints
158
+ query.bulk = query.bulk === "true" || query.bulk === "1"
159
+
160
+ // req.query is read-only since Express 5, so this is a hack.
161
+ // better create a custom query parser instead
162
+ // See <https://stackoverflow.com/questions/79597051/is-there-any-way-to-modify-req-query-in-express-v5>
163
+ Object.defineProperty(
164
+ req,
165
+ "query",
166
+ {
167
+ ...Object.getOwnPropertyDescriptor(req, "query"),
168
+ writable: false,
169
+ value: query,
170
+ })
171
+ }
172
+
173
+ // req.path -> req.type
174
+ let type = req.path.substring(1)
175
+ type = type.substring(0, type.indexOf("/") == -1 ? type.length : type.indexOf("/"))
176
+ if (type == "voc") {
177
+ if (req.path.includes("/top") || (req.path.includes("/concepts") && req.method !== "DELETE")) {
178
+ type = "concepts"
179
+ } else {
180
+ type = "schemes"
181
+ }
182
+ }
183
+ if (type == "mappings") {
184
+ if (req.path.includes("/suggest")) {
185
+ type = "suggest"
186
+ } else if (req.path.includes("/voc")) {
187
+ type = "schemes"
188
+ }
189
+ }
190
+ if (["concepts", "narrower", "ancestors", "search"].includes(type)) {
191
+ if (req.path.includes("/suggest")) {
192
+ type = "suggest"
193
+ } else {
194
+ type = "concepts"
195
+ }
196
+ }
197
+ if (type == "suggest" && _.get(req, "query.format", "").toLowerCase() == "jskos") {
198
+ type = "concepts"
199
+ }
200
+ req.type = type
201
+
202
+ // Add req.action
203
+ const action = {
204
+ GET: "read",
205
+ POST: "create",
206
+ PUT: "update",
207
+ PATCH: "update",
208
+ DELETE: "delete",
209
+ }[req.method]
210
+ req.action = action
211
+ // Add req.anonymous, req.crossUser, and req.auth if necessary
212
+ if (config[type] && config[type].anonymous) {
213
+ req.anonymous = true
214
+ }
215
+ if (["PUT", "PATCH", "DELETE"].includes(req.method)) {
216
+ if (config[type] && config[type][action] && config[type][action].crossUser) {
217
+ req.crossUser = config[type][action].crossUser
218
+ }
219
+ }
220
+ if (config[type] && config[type][action] && config[type][action].auth) {
221
+ req.auth = true
222
+ }
223
+ next()
224
+ }
225
+
226
+ /**
227
+ * Middleware that receives a list of supported download formats and overrides req.query.download if the requested format is not supported.
228
+ *
229
+ * @param {Array} formats
230
+ */
231
+ const supportDownloadFormats = (formats) => (req, res, next) => {
232
+ if (req.query.download && !formats.includes(req.query.download)) {
233
+ req.query.download = null
234
+ }
235
+ next()
236
+ }
237
+
238
+ /**
239
+ * Sets pagination headers (X-Total-Count, Link) for a response.
240
+ * See also: https://developer.github.com/v3/#pagination
241
+ * For Link header rels:
242
+ * - first and last are always set
243
+ * - prev will be set if previous page exists (i.e. if offset > 0)
244
+ * - next will be set if next page exists (i.e. if offset + limit < total)
245
+ *
246
+ * Requires req.data to be set.
247
+ */
248
+ const addPaginationHeaders = (req, res, next) => {
249
+ const limit = req.query.limit
250
+ const offset = req.query.offset
251
+ const total = req.data?.totalCount ?? req.data?.length ?? null
252
+
253
+ if (req == null || res == null || limit == null || offset == null) {
254
+ next()
255
+ return
256
+ }
257
+ // Set X-Total-Count header
258
+ if (total === null) {
259
+ // ! This is a workaround! We don't know the total number, so we just return an unreasonably high number here. See #176.
260
+ res.set("X-Total-Count", 9999999)
261
+ } else {
262
+ res.set("X-Total-Count", total)
263
+ }
264
+ let links = []
265
+ let query = _.cloneDeep(req.query)
266
+ query.limit = limit
267
+ // rel: first
268
+ query.offset = 0
269
+ links.push(buildUrlForLinkHeader({ req, query, rel: "first" }))
270
+ // rel: prev
271
+ if (offset > 0) {
272
+ query.offset = Math.max(offset - limit, 0)
273
+ links.push(buildUrlForLinkHeader({ req, query, rel: "prev" }))
274
+ }
275
+ // rel: next
276
+ if (total && limit + offset < total || req.data && req.data.length === limit) {
277
+ query.offset = offset + limit
278
+ links.push(buildUrlForLinkHeader({ req, query, rel: "next" }))
279
+ }
280
+ // rel: last
281
+ if (total !== null) {
282
+ let current = 0
283
+ while (current + limit < total) {
284
+ current += limit
285
+ }
286
+ query.offset = current
287
+ links.push(buildUrlForLinkHeader({ req, query, rel: "last" }))
288
+ } else if (req.data.length < limit) {
289
+ // Current page is last
290
+ links.push(buildUrlForLinkHeader({ req, query, rel: "last" }))
291
+ }
292
+ // Push existing Link header to the back
293
+ links.push(res.get("Link"))
294
+ // Set Link header
295
+ res.set("Link", links.join(","))
296
+ next()
297
+ }
298
+
299
+ /**
300
+ * Middleware that returns JSON given in req.data.
301
+ */
302
+ const returnJSON = (req, res) => {
303
+ // Convert Mongoose documents into plain objects
304
+ let data
305
+ if (Array.isArray(req.data)) {
306
+ data = req.data.map(doc => doc?.toObject ? doc.toObject() : doc)
307
+ // Preserve totalCount
308
+ data.totalCount = req.data.totalCount
309
+ } else {
310
+ data = req.data?.toObject ? req.data?.toObject() : req.data
311
+ }
312
+ cleanJSON(data, 0, config.closedWorldAssumption)
313
+ let statusCode = 200
314
+ if (req.method == "POST") {
315
+ statusCode = 201
316
+ }
317
+ res.status(statusCode).json(data)
318
+ }
319
+
320
+ /**
321
+ * Middleware that handles download streaming.
322
+ * Requires a database cursor in req.data.
323
+ *
324
+ * @param {String} filename - resulting filename without extension
325
+ */
326
+ const handleDownload = (filename) => (req, res) => {
327
+ let results = req.data, single = false
328
+ // Convert to stream if necessary
329
+ if (!(results instanceof Readable)) {
330
+ if (!Array.isArray(results)) {
331
+ single = true
332
+ }
333
+ results = new Readable({ objectMode: true })
334
+ results.push(req.data)
335
+ results.push(null)
336
+ }
337
+ /**
338
+ * Transformation object to remove _id parameter from objects in a stream.
339
+ */
340
+ const removeIdTransform = new Transform({
341
+ objectMode: true,
342
+ transform(chunk, encoding, callback) {
343
+ cleanJSON(chunk)
344
+ this.push(chunk)
345
+ callback()
346
+ },
347
+ })
348
+ // Default transformation: JSON
349
+ let transform = JSONStream.stringify(single ? "" : "[\n\t", ",\n\t", single ? "\n" : "\n]\n")
350
+ let fileEnding = "json"
351
+ let first = true, delimiter = ","
352
+ let csv
353
+ switch (req.query.download) {
354
+ case "ndjson":
355
+ fileEnding = "ndjson"
356
+ res.set("Content-Type", "application/x-ndjson; charset=utf-8")
357
+ transform = new Transform({
358
+ objectMode: true,
359
+ transform(chunk, encoding, callback) {
360
+ this.push(JSON.stringify(chunk) + "\n")
361
+ callback()
362
+ },
363
+ })
364
+ break
365
+ case "csv":
366
+ case "tsv":
367
+ fileEnding = req.query.download
368
+ if (req.query.download == "csv") {
369
+ delimiter = ","
370
+ res.set("Content-Type", "text/csv; charset=utf-8")
371
+ } else {
372
+ delimiter = "\t"
373
+ res.set("Content-Type", "text/tab-separated-values; charset=utf-8")
374
+ }
375
+ csv = jskos.mappingCSV({
376
+ lineTerminator: "\r\n",
377
+ creator: true,
378
+ schemes: true,
379
+ delimiter,
380
+ })
381
+ transform = new Transform({
382
+ objectMode: true,
383
+ transform(chunk, encoding, callback) {
384
+ // Small workaround to prepend a line to CSV
385
+ if (first) {
386
+ this.push(`"fromScheme"${delimiter}"fromNotation"${delimiter}"toScheme"${delimiter}"toNotation"${delimiter}"toNotation2"${delimiter}"toNotation3"${delimiter}"toNotation4"${delimiter}"toNotation5"${delimiter}"type"${delimiter}"creator"\n`)
387
+ first = false
388
+ }
389
+ this.push(csv.fromMapping(chunk, { fromCount: 1, toCount: 5 }))
390
+ callback()
391
+ },
392
+ })
393
+ break
394
+ }
395
+ // Add file header
396
+ res.set("Content-disposition", `attachment; filename=${filename}.${fileEnding}`)
397
+ // results is a database cursor
398
+ results
399
+ .pipe(removeIdTransform)
400
+ .pipe(transform)
401
+ .pipe(res)
402
+ }
403
+
404
+ /**
405
+ * Extracts a creator objects from a request.
406
+ *
407
+ * @param {*} req request object
408
+ */
409
+ const getCreator = (req) => {
410
+ let creator = {}
411
+ const creatorUriPath = req.type === "annotations" ? "id" : "uri"
412
+ const creatorNamePath = req.type === "annotations" ? "name" : "prefLabel.en"
413
+ const userUris = getUrisOfUser(req.user)
414
+ if (req.user && !userUris.includes(req.query.identity)) {
415
+ _.set(creator, creatorUriPath, req.user.uri)
416
+ } else if (req.query.identity) {
417
+ _.set(creator, creatorUriPath, req.query.identity)
418
+ }
419
+ if (req.query.identityName) {
420
+ _.set(creator, creatorNamePath, req.query.identityName)
421
+ } else if (req.query.identityName !== "") {
422
+ const name = _.get(Object.values(_.get(req, "user.identities", [])).find(i => i.uri === _.get(creator, creatorUriPath)) || req.user, "name")
423
+ if (name) {
424
+ _.set(creator, creatorNamePath, name)
425
+ }
426
+ }
427
+ if (!_.get(creator, creatorUriPath) && !_.get(creator, creatorNamePath)) {
428
+ creator = null
429
+ }
430
+ return creator
431
+ }
432
+
433
+ /**
434
+ * See https://github.com/gbv/jskos-server/issues/153#issuecomment-997847433
435
+ *
436
+ * @param {Object} options.object JSKOS object
437
+ * @param {Object} [options.existing] existing object from database for PUT/PATCH
438
+ * @param {Object} [options.creator] creator object, usually extracted via `getCreator` above
439
+ * @param {Object} options.req request object (necessary for `type`, `user`, `method`, `anonymous`, and `auth`)
440
+ */
441
+ const handleCreatorForObject = ({ object, existing, creator, req }) => {
442
+ if (!object) {
443
+ return object
444
+ }
445
+
446
+ if (req.type === "annotations") {
447
+ // No "contributor" for annotations
448
+ delete object.contributor
449
+ } else if (creator) {
450
+ // JSKOS creator has to be an array
451
+ creator = [creator]
452
+ }
453
+
454
+ const userUris = getUrisOfUser(req.user)
455
+ const anonymous = req.anonymous
456
+ const auth = req.auth
457
+
458
+ if (req.method === "POST") {
459
+ if (anonymous) {
460
+ delete object.creator
461
+ delete object.contributor
462
+ } else if (auth) {
463
+ if (creator) {
464
+ object.creator = creator
465
+ } else {
466
+ delete object.creator
467
+ }
468
+ }
469
+ } else if (req.method === "PUT" || req.method === "PATCH") {
470
+ if (anonymous) {
471
+ if (req.method === "PUT") {
472
+ object.creator = existing && existing.creator
473
+ object.contributor = existing && existing.contributor
474
+ } else {
475
+ delete object.creator
476
+ delete object.contributor
477
+ }
478
+ } else if (auth) {
479
+ if (existing && existing.creator) {
480
+ // Don't allow overriding existing creator
481
+ if (req.method === "PUT") {
482
+ object.creator = existing.creator
483
+ } else {
484
+ delete object.creator
485
+ }
486
+ } else if (object.creator && creator) {
487
+ // If creator is overridden, it can only be the user
488
+ object.creator = creator
489
+ }
490
+ // Update creator and/or add to contributor
491
+ if (creator) {
492
+ if (req.type === "annotations") {
493
+ // Only update creator if it's the user
494
+ if (userUris.includes((object.creator || existing && existing.creator || {}).id)) {
495
+ object.creator = creator
496
+ }
497
+ } else {
498
+ const findUserPredicate = c => jskos.compare(c, { identifier: userUris })
499
+ const objectCreatorIndex = (object.creator || []).findIndex(findUserPredicate)
500
+ const existingCreatorIndex = (existing && existing.creator || []).findIndex(findUserPredicate)
501
+ const objectContributorIndex = (object.contributor || []).findIndex(findUserPredicate)
502
+ const existingContributorIndex = (existing && existing.contributor || []).findIndex(findUserPredicate)
503
+ if (objectCreatorIndex !== -1) {
504
+ object.creator[objectCreatorIndex] = creator[0]
505
+ } else if (objectContributorIndex !== -1) {
506
+ object.contributor.splice(objectContributorIndex, 1)
507
+ object.contributor.push(creator[0])
508
+ } else if (existingCreatorIndex !== -1 && !object.creator) {
509
+ object.creator = existing.creator
510
+ object.creator[existingCreatorIndex] = creator[0]
511
+ } else if (existingContributorIndex !== -1 && !object.contributor) {
512
+ object.contributor = existing.contributor
513
+ object.contributor.splice(existingContributorIndex, 1)
514
+ object.contributor.push(creator[0])
515
+ } else {
516
+ object.contributor = (object.contributor || existing.contributor || []).concat(creator)
517
+ }
518
+ }
519
+ }
520
+ }
521
+ }
522
+ return object
523
+ }
524
+
525
+ /**
526
+ * Custom body parser middleware.
527
+ * - For POSTs, adds body stream via json-anystream and adjusts objects via handleCreatorForObject.
528
+ * - For PUT/PATCH/DELETE, parses JSON body, queries the existing entity which is saved in req.existing, checks creator, and adjusts object via handleCreatorForObject.
529
+ *
530
+ * @param {*} req
531
+ * @param {*} res
532
+ * @param {*} next
533
+ */
534
+ const bodyParser = (req, res, next) => {
535
+
536
+ // Assemble creator once
537
+ const creator = getCreator(req)
538
+
539
+ // Wrap handleCreatorForObject method
540
+ const adjust = (object, existing) => {
541
+ return handleCreatorForObject({
542
+ object,
543
+ existing,
544
+ creator,
545
+ req,
546
+ })
547
+ }
548
+
549
+ if (req.method == "POST") {
550
+ // For POST requests, parse body with json-anystream middleware
551
+ anystream.addStream(adjust)(req, res, next)
552
+ } else {
553
+ // For all other requests, parse as JSON
554
+ express.json()(req, res, async (...params) => {
555
+ // Get existing
556
+ const uri = req.params._id || (req.body || {}).uri || req.query.uri
557
+ let existing
558
+ try {
559
+ existing = await services[req.type].getItem(uri)
560
+ } catch (error) {
561
+ // Ignore
562
+ }
563
+ if (!existing) {
564
+ next(new EntityNotFoundError(null, uri))
565
+ } else {
566
+ // Override certain properties with entities from database
567
+ // Note: For POST request, this needs to be done individually in the services/{entity}.js file.
568
+ if (["mappings", "annotations"].includes(req.type)) {
569
+ await services.schemes.replaceSchemeProperties(req.body, ["fromScheme", "toScheme"])
570
+ await services.schemes.replaceSchemeProperties(existing, ["fromScheme", "toScheme"])
571
+ }
572
+ let superordinated = {
573
+ existing: null,
574
+ payload: null,
575
+ }
576
+ // Check for superordinated object for existing (currently only `partOf`)
577
+ if (req.type === "mappings" && existing.partOf && existing.partOf[0]) {
578
+ // Get concordance via service
579
+ try {
580
+ const concordance = await services.concordances.getItem(existing.partOf[0].uri)
581
+ superordinated.existing = concordance
582
+ } catch (error) {
583
+ const message = `Existing concordance with URI ${existing.partOf[0].uri} could not be found in database.`
584
+ config.error(message)
585
+ next(new DatabaseInconsistencyError(message))
586
+ }
587
+ }
588
+ // Check superordinated object for payload
589
+ if (req.type === "mappings" && req.body && req.body.partOf && req.body.partOf[0]) {
590
+ // Get concordance via service
591
+ try {
592
+ const concordance = await services.concordances.getItem(req.body.partOf[0].uri)
593
+ superordinated.payload = concordance
594
+ } catch (error) {
595
+ next(new InvalidBodyError(`Concordance with URI ${req.body.partOf[0].uri} could not be found.`))
596
+ }
597
+ }
598
+ let creatorMatches = true
599
+ if (superordinated.existing) {
600
+ // creator or contributor must match for existing superordinated object
601
+ creatorMatches = creatorMatches && matchesCreator({ req, object: superordinated.existing, withContributors: true })
602
+ } else {
603
+ // creator needs to match for object that is updated
604
+ creatorMatches = creatorMatches && matchesCreator({ req, object: existing })
605
+ }
606
+ if (superordinated.payload) {
607
+ // creator or contributor must also match for the payload's superordinated object
608
+ creatorMatches = creatorMatches && matchesCreator({ req, object: superordinated.payload, withContributors: true })
609
+ }
610
+ if (!creatorMatches) {
611
+ next(new CreatorDoesNotMatchError())
612
+ } else {
613
+ req.existing = existing
614
+ req.body = adjust(req.body, existing)
615
+ next(...params)
616
+ }
617
+ }
618
+ })
619
+ }
620
+ }
621
+
622
+ export {
623
+ wrapAsync,
624
+ wrapDownload,
625
+ adjust,
626
+ matchesCreator,
627
+ addDefaultHeaders,
628
+ supportDownloadFormats,
629
+ addMiddlewareProperties,
630
+ addPaginationHeaders,
631
+ returnJSON,
632
+ handleDownload,
633
+ bodyParser,
634
+ getCreator,
635
+ handleCreatorForObject,
636
+ }