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.
- package/.dockerignore +20 -0
- package/.editorconfig +9 -0
- package/.github/workflows/docker.yml +59 -0
- package/.github/workflows/gh-pages.yml +23 -0
- package/.github/workflows/gh-release.yml +19 -0
- package/.github/workflows/test.yml +39 -0
- package/.husky/pre-commit +1 -0
- package/CHANGELOG.md +18 -0
- package/LICENSE +21 -0
- package/README.md +2710 -0
- package/bin/extra.js +81 -0
- package/bin/import.js +438 -0
- package/bin/mongodb.js +21 -0
- package/bin/reset.js +257 -0
- package/bin/upgrade.js +34 -0
- package/config/config.default.json +88 -0
- package/config/config.schema.json +877 -0
- package/config/config.test.json +107 -0
- package/config/index.js +77 -0
- package/config/setup.js +212 -0
- package/depdendencies.png +0 -0
- package/docker/.env +1 -0
- package/docker/Dockerfile +20 -0
- package/docker/README.md +175 -0
- package/docker/docker-compose.yml +29 -0
- package/docker/docker-entrypoint.sh +8 -0
- package/docker/mongo-initdb.d/mongo_setup.sh +22 -0
- package/ecosystem.example.json +7 -0
- package/errors/index.js +94 -0
- package/eslint.config.js +17 -0
- package/index.js +10 -0
- package/models/annotations.js +13 -0
- package/models/concepts.js +12 -0
- package/models/concordances.js +12 -0
- package/models/index.js +33 -0
- package/models/mappings.js +20 -0
- package/models/meta.js +21 -0
- package/models/registries.js +12 -0
- package/models/schemes.js +12 -0
- package/package.json +91 -0
- package/routes/annotations.js +83 -0
- package/routes/common.js +86 -0
- package/routes/concepts.js +64 -0
- package/routes/concordances.js +86 -0
- package/routes/data.js +19 -0
- package/routes/mappings.js +108 -0
- package/routes/registries.js +24 -0
- package/routes/schemes.js +72 -0
- package/routes/validate.js +37 -0
- package/server.js +190 -0
- package/services/abstract.js +328 -0
- package/services/annotations.js +237 -0
- package/services/concepts.js +459 -0
- package/services/concordances.js +264 -0
- package/services/data.js +30 -0
- package/services/index.js +34 -0
- package/services/mappings.js +978 -0
- package/services/registries.js +319 -0
- package/services/schemes.js +318 -0
- package/services/validate.js +39 -0
- package/status.schema.json +145 -0
- package/test/abstract-service.js +36 -0
- package/test/annotations/annotation.json +13 -0
- package/test/api.js +2481 -0
- package/test/chai.js +14 -0
- package/test/changes.js +179 -0
- package/test/concepts/conceptNoFileEnding +4 -0
- package/test/concepts/concepts-ddc-6-60-61-62.json +123 -0
- package/test/concordances/concordances.ndjson +2 -0
- package/test/config.js +26 -0
- package/test/configs/complex-config.json +90 -0
- package/test/configs/empty-object.json +1 -0
- package/test/configs/fail-array.json +1 -0
- package/test/configs/fail-empty.json +0 -0
- package/test/configs/fail-mapping-only-props1.json +5 -0
- package/test/configs/fail-mapping-only-props2.json +5 -0
- package/test/configs/fail-mapping-only-props3.json +5 -0
- package/test/configs/fail-mapping-only-props4.json +5 -0
- package/test/configs/fail-nonexisting-prop.json +3 -0
- package/test/configs/fail-port-string.json +3 -0
- package/test/configs/fail-registry-types.json +16 -0
- package/test/configs/registry-types.json +16 -0
- package/test/data-write.js +784 -0
- package/test/eslint.js +22 -0
- package/test/import-reset.js +287 -0
- package/test/infer-mappings.js +340 -0
- package/test/ipcheck.js +287 -0
- package/test/mappings/README.md +1 -0
- package/test/mappings/ddc-gnd-1.mapping.json +33 -0
- package/test/mappings/ddc-gnd-2.mapping.json +67 -0
- package/test/mappings/mapping-ddc-gnd-noScheme.json +145 -0
- package/test/mappings/mapping-ddc-gnd.json +175 -0
- package/test/mappings/mappings-ddc.json +214 -0
- package/test/registries/registries.ndjson +2 -0
- package/test/services.js +557 -0
- package/test/terminologies/terminologies.json +94 -0
- package/test/test-utils.js +182 -0
- package/test/utils.js +425 -0
- package/test/validate.js +226 -0
- package/utils/adjust.js +206 -0
- package/utils/auth.js +154 -0
- package/utils/changes.js +88 -0
- package/utils/db.js +106 -0
- package/utils/ipcheck.js +76 -0
- package/utils/middleware.js +636 -0
- package/utils/searchHelper.js +153 -0
- package/utils/status.js +77 -0
- package/utils/users.js +7 -0
- package/utils/utils.js +114 -0
- package/utils/uuid.js +6 -0
- package/utils/version.js +324 -0
- 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
|
+
}
|