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,319 @@
|
|
|
1
|
+
import _ from "lodash"
|
|
2
|
+
import { removeNullProperties } from "../utils/utils.js"
|
|
3
|
+
import { validate } from "jskos-validate"
|
|
4
|
+
import { Registry } from "../models/registries.js"
|
|
5
|
+
import { addKeywords } from "../utils/searchHelper.js"
|
|
6
|
+
import { EntityNotFoundError, DatabaseAccessError, InvalidBodyError, MalformedBodyError } from "../errors/index.js"
|
|
7
|
+
|
|
8
|
+
import { AbstractService } from "./abstract.js"
|
|
9
|
+
|
|
10
|
+
// TODO: get via factory Method
|
|
11
|
+
import { models } from "../models/index.js"
|
|
12
|
+
|
|
13
|
+
export class RegistryService extends AbstractService {
|
|
14
|
+
static allMemberTypes = ["schemes", "concepts", "mappings", "concordances", "annotations", "registries"]
|
|
15
|
+
|
|
16
|
+
constructor(config) {
|
|
17
|
+
super(config)
|
|
18
|
+
this.config = config.registries || {}
|
|
19
|
+
this.types = {}
|
|
20
|
+
|
|
21
|
+
this.model = Registry
|
|
22
|
+
|
|
23
|
+
// TODO: duplicated code in config.setup
|
|
24
|
+
for (let type of RegistryService.allMemberTypes) {
|
|
25
|
+
this.types[type] = config.types?.[type]
|
|
26
|
+
if (this.types[type] === true) {
|
|
27
|
+
this.types[type] = { mustExist: false, skipInvalid: false }
|
|
28
|
+
}
|
|
29
|
+
if (this.types[type] && !("uriRequired" in this.types[type])) {
|
|
30
|
+
this.types[type].uriRequired = true
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Retrieves registry entries.
|
|
37
|
+
*
|
|
38
|
+
* @param {Object} query - Query parameters controlling pagination.
|
|
39
|
+
* @param {number|string} [query.limit=100] - Maximum number of registries to fetch.
|
|
40
|
+
* @param {number|string} [query.offset=0] - Number of registries to skip before fetching.
|
|
41
|
+
* @returns {Promise<Object[]>} A promise that resolves to the matching registries.
|
|
42
|
+
*/
|
|
43
|
+
async queryItems(query) {
|
|
44
|
+
const { limit, offset } = this._getLimitAndOffset(query)
|
|
45
|
+
return this.model.find({}).skip(offset).limit(limit).lean().exec()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Prepares and checks a registry before inserting/updating:
|
|
50
|
+
* - validates object, throws error if it doesn't (create/update)
|
|
51
|
+
* - add `_id` property (create/update)
|
|
52
|
+
* - add search keyword fields for text index (create/update)
|
|
53
|
+
*
|
|
54
|
+
* @param {Object} registry registry object
|
|
55
|
+
* @param {string} action one of "create" or "update"
|
|
56
|
+
* @returns {Object} prepared registry
|
|
57
|
+
*/
|
|
58
|
+
async prepareAndCheckItemForAction(registry, action) {
|
|
59
|
+
if (typeof registry !== "object") {
|
|
60
|
+
throw new MalformedBodyError("Invalid registry object")
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (["create", "update"].includes(action)) {
|
|
64
|
+
// Validate registry
|
|
65
|
+
if (!validate.registry(registry)) {
|
|
66
|
+
// TODO: use error object
|
|
67
|
+
const msgs = validate.registry.errorMessages || ["Registry validation failed"]
|
|
68
|
+
throw new InvalidBodyError(msgs.join("; "))
|
|
69
|
+
}
|
|
70
|
+
if (!registry.uri) {
|
|
71
|
+
// TODO: how about minting URIs?
|
|
72
|
+
throw new InvalidBodyError("Registry lacks uri")
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
await this.processMembers(registry)
|
|
76
|
+
|
|
77
|
+
// Add _id
|
|
78
|
+
registry._id = registry.uri
|
|
79
|
+
|
|
80
|
+
// Add index keywords
|
|
81
|
+
addKeywords(registry)
|
|
82
|
+
|
|
83
|
+
// Remove created for update action // TODO: why?
|
|
84
|
+
if (action === "update") {
|
|
85
|
+
delete registry.created
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return registry
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Updates an existing registry entry, while preserving immutable fields.
|
|
94
|
+
*
|
|
95
|
+
* @param {Object} params - Parameters for updating the registry.
|
|
96
|
+
* @param {Object} params.body - The new registry data to store.
|
|
97
|
+
* @param {Object} params.existing - The existing registry document from the database.
|
|
98
|
+
* @returns {Promise<Object>} The updated registry document.
|
|
99
|
+
* @throws {InvalidBodyError} If the request body is missing or fails validation.
|
|
100
|
+
* @throws {EntityNotFoundError} If the targeted registry is not found.
|
|
101
|
+
* @throws {DatabaseAccessError} If the database operation fails.
|
|
102
|
+
*/
|
|
103
|
+
async updateItem({ body, existing }) {
|
|
104
|
+
if (!body) {
|
|
105
|
+
throw new InvalidBodyError()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
body.modified = new Date().toISOString()
|
|
109
|
+
|
|
110
|
+
delete body.type
|
|
111
|
+
|
|
112
|
+
// Validate registry
|
|
113
|
+
if (!validate.registry(body)) {
|
|
114
|
+
const msgs = validate.registry?.errorMessages || []
|
|
115
|
+
throw new InvalidBodyError(
|
|
116
|
+
msgs.join("; ") || "Registry validation failed",
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
await this.processMembers(body)
|
|
121
|
+
|
|
122
|
+
// Preserve existing property: created date
|
|
123
|
+
body.created = existing.created
|
|
124
|
+
|
|
125
|
+
// Override _id and id properties
|
|
126
|
+
body.id = existing.id
|
|
127
|
+
body._id = existing._id
|
|
128
|
+
|
|
129
|
+
// Replace in database
|
|
130
|
+
const result = await this.model.replaceOne({ _id: existing._id }, body)
|
|
131
|
+
if (!result.matchedCount) {
|
|
132
|
+
throw new EntityNotFoundError(`Registry not found: ${existing._id}`)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Confirm that the update was acknowledged
|
|
136
|
+
if (!result.acknowledged) {
|
|
137
|
+
throw new DatabaseAccessError()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Return the updated registry entry
|
|
141
|
+
const doc = await this.model.findById(existing._id).lean()
|
|
142
|
+
if (!doc) {
|
|
143
|
+
throw new DatabaseAccessError()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return doc
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Partially updates a registry entry.
|
|
151
|
+
*
|
|
152
|
+
* @async
|
|
153
|
+
* @function patchRegistry
|
|
154
|
+
* @param {Object} params - Parameters for updating a registry.
|
|
155
|
+
* @param {Object} params.body - Incoming registry fields to merge into the existing entry.
|
|
156
|
+
* @param {Object} params.existing - The existing registry document from the database to be updated.
|
|
157
|
+
* @throws {InvalidBodyError} If the request body is missing or fails validation.
|
|
158
|
+
* @throws {EntityNotFoundError} If the target registry does not exist.
|
|
159
|
+
* @throws {DatabaseAccessError} If the database update fails.
|
|
160
|
+
* @returns {Promise<Object>} The updated registry document from the database.
|
|
161
|
+
*/
|
|
162
|
+
async patchRegistry({ body, existing }) {
|
|
163
|
+
if (!body) {
|
|
164
|
+
throw new InvalidBodyError()
|
|
165
|
+
}
|
|
166
|
+
if (!existing?._id) {
|
|
167
|
+
throw new EntityNotFoundError("Registry not found")
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
existing.modified = new Date().toISOString()
|
|
171
|
+
|
|
172
|
+
// Protect identity + server-managed fields
|
|
173
|
+
for (let key of ["_id", "id", "uri", "created"]) {
|
|
174
|
+
delete body[key]
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Merge existing with updates
|
|
178
|
+
_.assign(existing, body)
|
|
179
|
+
|
|
180
|
+
removeNullProperties(existing)
|
|
181
|
+
|
|
182
|
+
await this.processMembers(existing)
|
|
183
|
+
|
|
184
|
+
// Validate merged object
|
|
185
|
+
if (!validate.registry(existing)) {
|
|
186
|
+
const msgs = validate.registry.errorMessages || []
|
|
187
|
+
throw new InvalidBodyError(
|
|
188
|
+
msgs.join("; ") || "Registry validation failed",
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Replace in database
|
|
193
|
+
const result = await this.model.replaceOne({ _id: existing._id }, existing)
|
|
194
|
+
if (!result.matchedCount) {
|
|
195
|
+
throw new EntityNotFoundError(`Registry not found: ${existing._id}`)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Confirm that the update was acknowledged
|
|
199
|
+
if (!result.acknowledged) {
|
|
200
|
+
throw new DatabaseAccessError()
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Return the updated registry entry
|
|
204
|
+
const doc = await this.model.findById(existing._id).lean()
|
|
205
|
+
if (!doc) {
|
|
206
|
+
throw new DatabaseAccessError()
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return doc
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Initializes the Registry collection by creating it if absent, removing any existing indexes,
|
|
214
|
+
* and establishing the full set of required indexes
|
|
215
|
+
*/
|
|
216
|
+
async createIndexes() {
|
|
217
|
+
const indexes = []
|
|
218
|
+
indexes.push([{ uri: 1 }, {}])
|
|
219
|
+
indexes.push([{ identifier: 1 }, {}])
|
|
220
|
+
indexes.push([{ notation: 1 }, {}])
|
|
221
|
+
indexes.push([{ type: 1 }, {}])
|
|
222
|
+
|
|
223
|
+
indexes.push([{ created: 1 }, {}])
|
|
224
|
+
indexes.push([{ modified: 1 }, {}])
|
|
225
|
+
indexes.push([{ startDate: 1 }, {}])
|
|
226
|
+
indexes.push([{ endDate: 1 }, {}])
|
|
227
|
+
|
|
228
|
+
indexes.push([{ url: 1 }, {}])
|
|
229
|
+
indexes.push([{ "subject.uri": 1 }, {}])
|
|
230
|
+
|
|
231
|
+
// TODO: check this
|
|
232
|
+
indexes.push([{ _keywordsLabels: 1 }, {}])
|
|
233
|
+
indexes.push([{ _keywordsPublisher: 1 }, {}])
|
|
234
|
+
indexes.push([{ "_keywordsLabels.0": 1 }, {}])
|
|
235
|
+
indexes.push([
|
|
236
|
+
{
|
|
237
|
+
_keywordsNotation: "text",
|
|
238
|
+
_keywordsLabels: "text",
|
|
239
|
+
_keywordsOther: "text",
|
|
240
|
+
_keywordsPublisher: "text",
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
name: "text",
|
|
244
|
+
default_language: "german", // ??
|
|
245
|
+
weights: {
|
|
246
|
+
_keywordsNotation: 10,
|
|
247
|
+
_keywordsLabels: 6,
|
|
248
|
+
_keywordsOther: 3,
|
|
249
|
+
_keywordsPublisher: 3,
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
])
|
|
253
|
+
|
|
254
|
+
// Create collection if necessary
|
|
255
|
+
try {
|
|
256
|
+
await this.model.createCollection()
|
|
257
|
+
} catch (error) {
|
|
258
|
+
this.error("Error creating collection:", error)
|
|
259
|
+
// Ignore error
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Drop existing indexes
|
|
263
|
+
await this.model.collection.dropIndexes()
|
|
264
|
+
for (const [index, options] of indexes) {
|
|
265
|
+
await this.model.collection.createIndex(index, options)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Validates and filters registry member fields based on config.
|
|
271
|
+
*
|
|
272
|
+
* @param {Object} registry registry object
|
|
273
|
+
* @throws {InvalidBodyError} When a disallowed membership field is present.
|
|
274
|
+
*/
|
|
275
|
+
async processMembers(registry) {
|
|
276
|
+
const usedTypes = RegistryService.allMemberTypes.filter(type => registry[type])
|
|
277
|
+
|
|
278
|
+
if (!this.config.mixedTypes) {
|
|
279
|
+
if (usedTypes.length > 1) {
|
|
280
|
+
throw new InvalidBodyError(`Registry must not have multiple member types, found: ${usedTypes.join(", ")}`)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
for (let type of usedTypes) {
|
|
285
|
+
if (!this.config.types[type]) {
|
|
286
|
+
throw new InvalidBodyError(`Registry member type not allowed: ${type}`)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const { uriRequired, mustExist, skipInvalid } = this.config.types[type]
|
|
290
|
+
|
|
291
|
+
const validator = validate[type.replace(/ies$/,"y").replace(/s$/,"")]
|
|
292
|
+
|
|
293
|
+
let items = []
|
|
294
|
+
for (let item of registry[type].filter(item => item !== null)) {
|
|
295
|
+
let error
|
|
296
|
+
if (uriRequired && !item.uri) {
|
|
297
|
+
error = `missing ${type} uri in registry`
|
|
298
|
+
} else if (!validator(item)) {
|
|
299
|
+
error = `invalid ${type} in registry`
|
|
300
|
+
// TODO: use validator.errors error object
|
|
301
|
+
} else if (mustExist) {
|
|
302
|
+
const found = await models[type].findOne({ uri: item.uri }).lean()
|
|
303
|
+
if (!found) {
|
|
304
|
+
error = `${type} not found with uri: ${item.uri}`
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (error) {
|
|
308
|
+
if (!skipInvalid) {
|
|
309
|
+
throw new InvalidBodyError(error)
|
|
310
|
+
}
|
|
311
|
+
} else {
|
|
312
|
+
items.push(item)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
registry[type] = items
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
}
|
|
319
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import _ from "lodash"
|
|
2
|
+
import { validate } from "jskos-validate"
|
|
3
|
+
|
|
4
|
+
import { addKeywords } from "../utils/searchHelper.js"
|
|
5
|
+
import { MalformedBodyError, MalformedRequestError, EntityNotFoundError, DatabaseAccessError, InvalidBodyError } from "../errors/index.js"
|
|
6
|
+
import { Scheme } from "../models/schemes.js"
|
|
7
|
+
import { Concept } from "../models/concepts.js"
|
|
8
|
+
|
|
9
|
+
import { AbstractService } from "./abstract.js"
|
|
10
|
+
|
|
11
|
+
export class SchemeService extends AbstractService {
|
|
12
|
+
|
|
13
|
+
constructor(config) {
|
|
14
|
+
super(config)
|
|
15
|
+
this.baseUrl = config.baseUrl
|
|
16
|
+
this.model = Scheme
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Return a Promise with an array of vocabularies.
|
|
21
|
+
*/
|
|
22
|
+
async queryItems(query) {
|
|
23
|
+
let mongoQuery = {}
|
|
24
|
+
if (query.uri) {
|
|
25
|
+
mongoQuery = {
|
|
26
|
+
$or: query.uri.split("|").map(uri => ({ uri })).concat(query.uri.split("|").map(uri => ({ identifier: uri }))),
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (query.type) {
|
|
30
|
+
mongoQuery.type = query.type
|
|
31
|
+
}
|
|
32
|
+
if (query.languages) {
|
|
33
|
+
mongoQuery.languages = {
|
|
34
|
+
$in: query.languages.split(","),
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (query.subject) {
|
|
38
|
+
mongoQuery["subject.uri"] = {
|
|
39
|
+
$in: query.subject.split("|"),
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (query.license) {
|
|
43
|
+
mongoQuery["license.uri"] = {
|
|
44
|
+
$in: query.license.split("|"),
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (query.partOf) {
|
|
48
|
+
mongoQuery["partOf.uri"] = {
|
|
49
|
+
$in: query.partOf.split("|"),
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (query.publisher) {
|
|
53
|
+
mongoQuery._keywordsPublisher = query.publisher
|
|
54
|
+
}
|
|
55
|
+
if (query.notation) {
|
|
56
|
+
const notations = query.notation.split("|")
|
|
57
|
+
mongoQuery.notation = { $in: notations }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Sort order (default: asc = 1)
|
|
61
|
+
const order = query.order === "desc" ? -1 : 1
|
|
62
|
+
const sort = {}
|
|
63
|
+
switch (query.sort) {
|
|
64
|
+
case "label":
|
|
65
|
+
sort["_keywordsLabels.0"] = order
|
|
66
|
+
break
|
|
67
|
+
case "notation":
|
|
68
|
+
sort["_keywordsNotation.0"] = order
|
|
69
|
+
break
|
|
70
|
+
case "created":
|
|
71
|
+
sort["created"] = order
|
|
72
|
+
break
|
|
73
|
+
case "modified":
|
|
74
|
+
sort["modified"] = order
|
|
75
|
+
break
|
|
76
|
+
case "counter":
|
|
77
|
+
sort["_uriSuffixNumber"] = order
|
|
78
|
+
break
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Build aggregation pipeline
|
|
82
|
+
const pipeline = [
|
|
83
|
+
{ $match: mongoQuery },
|
|
84
|
+
]
|
|
85
|
+
if (query.sort === "counter") {
|
|
86
|
+
// Add an additional aggregation step to add _uriSuffixNumber property
|
|
87
|
+
pipeline.push({
|
|
88
|
+
$set: {
|
|
89
|
+
_uriSuffixNumber: {
|
|
90
|
+
$function: {
|
|
91
|
+
body: function (uri) {
|
|
92
|
+
return parseInt(uri.substring(uri.lastIndexOf("/") + 1))
|
|
93
|
+
},
|
|
94
|
+
args: ["$uri"],
|
|
95
|
+
lang: "js",
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
if (Object.keys(sort).length > 0) {
|
|
102
|
+
pipeline.push({ $sort: sort })
|
|
103
|
+
}
|
|
104
|
+
const { limit, offset } = this._getLimitAndOffset(query)
|
|
105
|
+
pipeline.push({ $skip: offset })
|
|
106
|
+
pipeline.push({ $limit: limit })
|
|
107
|
+
|
|
108
|
+
const schemes = await this.model.aggregate(pipeline)
|
|
109
|
+
schemes.totalCount = await this._count(Scheme, [{ $match: mongoQuery }])
|
|
110
|
+
|
|
111
|
+
return schemes
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async get(uri) {
|
|
115
|
+
return this.getScheme(uri)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async getScheme(identifierOrNotation) {
|
|
119
|
+
// TODO: Should we just throw an error 404 here?
|
|
120
|
+
if (!identifierOrNotation) {
|
|
121
|
+
return null
|
|
122
|
+
}
|
|
123
|
+
return await this.model.findOne({ $or: [{ uri: identifierOrNotation }, { identifier: identifierOrNotation }, { notation: new RegExp(`^${_.escapeRegExp(identifierOrNotation)}$`, "i") }] }).lean().exec()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async replaceSchemeProperties(entity, propertyPaths, ignoreError = true) {
|
|
127
|
+
await Promise.all(propertyPaths.map(async path => {
|
|
128
|
+
const uri = _.get(entity, `${path}.uri`)
|
|
129
|
+
let scheme
|
|
130
|
+
try {
|
|
131
|
+
scheme = await this.getScheme(uri)
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (!ignoreError) {
|
|
134
|
+
throw new InvalidBodyError(`Scheme with URI ${uri} not found. Only known schemes can be used.`)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (scheme) {
|
|
138
|
+
_.set(entity, path, _.pick(scheme, ["uri", "notation"]))
|
|
139
|
+
} else if (!ignoreError) {
|
|
140
|
+
throw new InvalidBodyError(`Scheme with URI ${uri} not found. Only known schemes can be used.`)
|
|
141
|
+
}
|
|
142
|
+
}))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Return a Promise with an array of suggestions in JSKOS format.
|
|
147
|
+
*/
|
|
148
|
+
async search(query) {
|
|
149
|
+
let search = query.query || query.search || ""
|
|
150
|
+
let results = await this._searchItems({ search })
|
|
151
|
+
const searchResults = results.slice(query.offset, query.offset + query.limit)
|
|
152
|
+
searchResults.totalCount = results.length
|
|
153
|
+
return searchResults
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Write endpoints start here
|
|
157
|
+
|
|
158
|
+
async updateItem({ body, existing, ...args }) {
|
|
159
|
+
let item = body
|
|
160
|
+
|
|
161
|
+
// Prepare
|
|
162
|
+
item = await this.prepareAndCheckItemForAction(item, "update")
|
|
163
|
+
|
|
164
|
+
// Override _id, uri, and created properties
|
|
165
|
+
item._id = existing._id
|
|
166
|
+
item.uri = existing.uri
|
|
167
|
+
item.created = existing.created
|
|
168
|
+
|
|
169
|
+
// Write item to database
|
|
170
|
+
const result = await this.model.replaceOne({ _id: item.uri }, item)
|
|
171
|
+
if (!result.acknowledged) {
|
|
172
|
+
throw new DatabaseAccessError()
|
|
173
|
+
}
|
|
174
|
+
if (!result.matchedCount) {
|
|
175
|
+
throw new EntityNotFoundError()
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return (await this.postAdjustmentsForItems([item], args))[0]
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async deleteItem({ uri, existing }) {
|
|
182
|
+
if (!uri) {
|
|
183
|
+
throw new MalformedRequestError()
|
|
184
|
+
}
|
|
185
|
+
if (existing.concepts.length) {
|
|
186
|
+
// Disallow deletion
|
|
187
|
+
// ? Which error type?
|
|
188
|
+
throw new MalformedRequestError(`Concept scheme ${uri} still has concepts in the database and therefore can't be deleted.`)
|
|
189
|
+
}
|
|
190
|
+
super.deleteItem({ existing })
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Prepares and checks a concept scheme before inserting/updating:
|
|
195
|
+
* - validates object, throws error if it doesn't (create/update)
|
|
196
|
+
* - add `_id` property (create/update)
|
|
197
|
+
* - check if it exists, throws error if it doesn't (delete)
|
|
198
|
+
* - check if it has existing concepts in database, throws error if it has (delete)
|
|
199
|
+
*
|
|
200
|
+
* @param {Object} scheme concept scheme object
|
|
201
|
+
* @param {string} action one of "create", "update", and "delete"
|
|
202
|
+
* @returns {Object} prepared concept scheme
|
|
203
|
+
*/
|
|
204
|
+
async prepareAndCheckItemForAction(scheme, action) {
|
|
205
|
+
if (!_.isObject(scheme)) {
|
|
206
|
+
throw new MalformedBodyError()
|
|
207
|
+
}
|
|
208
|
+
if (["create", "update"].includes(action)) {
|
|
209
|
+
if (!validate.scheme(scheme) || !scheme.uri) {
|
|
210
|
+
throw new InvalidBodyError()
|
|
211
|
+
}
|
|
212
|
+
scheme._id = scheme.uri
|
|
213
|
+
addKeywords(scheme)
|
|
214
|
+
if (action === "update") {
|
|
215
|
+
delete scheme.created
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return scheme
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Post-adjustments for concept schemes:
|
|
223
|
+
* - update `concepts` property
|
|
224
|
+
* - update `topConcepts` property
|
|
225
|
+
* - get updated concept scheme from database
|
|
226
|
+
*
|
|
227
|
+
* @param {[Object]} schemes array of concept schemes to be adjusted
|
|
228
|
+
* @param {Boolean} options.bulk indicates whether the adjustments are performaned as part of a bulk operation
|
|
229
|
+
* @returns {[Object]} array of adjusted concept schemes
|
|
230
|
+
*/
|
|
231
|
+
async postAdjustmentsForItems(schemes, { bulk = false, setApi = false } = {}) {
|
|
232
|
+
// Get schemes from database instead
|
|
233
|
+
schemes = await this.model.find({ _id: { $in: schemes.map(s => s.uri) } }).lean().exec()
|
|
234
|
+
// First, set created field if necessary
|
|
235
|
+
await this.model.updateMany(
|
|
236
|
+
{
|
|
237
|
+
_id: { $in: schemes.map(s => s.uri) },
|
|
238
|
+
$or: [
|
|
239
|
+
{ created: { $eq: null } }, { created: { $exists: false } },
|
|
240
|
+
],
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
$set: {
|
|
244
|
+
created: (new Date()).toISOString(),
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
)
|
|
248
|
+
const result = []
|
|
249
|
+
for (let scheme of schemes) {
|
|
250
|
+
const hasTopConcepts = !!(await Concept.findOne({ $or: [scheme.uri].concat(scheme.identifier || []).map(uri => ({ "topConceptOf.uri": uri })) }))
|
|
251
|
+
const hasConcepts = hasTopConcepts || !!(await Concept.findOne({ $or: [scheme.uri].concat(scheme.identifier || []).map(uri => ({ "inScheme.uri": uri })) }))
|
|
252
|
+
const update = {
|
|
253
|
+
$set: {
|
|
254
|
+
concepts: hasConcepts ? [null] : [],
|
|
255
|
+
topConcepts: hasTopConcepts ? [null] : [],
|
|
256
|
+
modified: (new Date()).toISOString(),
|
|
257
|
+
},
|
|
258
|
+
}
|
|
259
|
+
if (setApi) {
|
|
260
|
+
let API = scheme.API || []
|
|
261
|
+
API = API.filter(entry => entry.url !== this.baseUrl)
|
|
262
|
+
if (hasConcepts) {
|
|
263
|
+
API = [
|
|
264
|
+
{
|
|
265
|
+
type: "http://bartoc.org/api-type/jskos",
|
|
266
|
+
url: this.baseUrl,
|
|
267
|
+
},
|
|
268
|
+
].concat(API)
|
|
269
|
+
}
|
|
270
|
+
if (API.length) {
|
|
271
|
+
_.set(update, "$set.API", API)
|
|
272
|
+
} else {
|
|
273
|
+
_.set(update, "$unset.API", "")
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (bulk) {
|
|
277
|
+
delete update.$set.modified
|
|
278
|
+
}
|
|
279
|
+
await this.model.updateOne({ _id: scheme.uri }, update)
|
|
280
|
+
result.push(await this.model.findById(scheme.uri))
|
|
281
|
+
}
|
|
282
|
+
return result
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async createIndexes() {
|
|
286
|
+
return this._createIndexes([
|
|
287
|
+
[{ uri: 1 }, {}],
|
|
288
|
+
[{ identifier: 1 }, {}],
|
|
289
|
+
[{ notation: 1 }, {}],
|
|
290
|
+
[{ created: 1 }, {}],
|
|
291
|
+
[{ modified: 1 }, {}],
|
|
292
|
+
[{ "subject.uri": 1 }, {}],
|
|
293
|
+
[{ "license.uri": 1 }, {}],
|
|
294
|
+
[{ "partOf.uri": 1 }, {}],
|
|
295
|
+
[{ _keywordsLabels: 1 }, {}],
|
|
296
|
+
[{ _keywordsPublisher: 1 }, {}],
|
|
297
|
+
// Add additional index for first entry which is used for sorting
|
|
298
|
+
[{ "_keywordsLabels.0": 1 }, {}],
|
|
299
|
+
[
|
|
300
|
+
{
|
|
301
|
+
_keywordsNotation: "text",
|
|
302
|
+
_keywordsLabels: "text",
|
|
303
|
+
_keywordsOther: "text",
|
|
304
|
+
_keywordsPublisher: "text",
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
name: "text",
|
|
308
|
+
default_language: "german",
|
|
309
|
+
weights: {
|
|
310
|
+
_keywordsNotation: 10,
|
|
311
|
+
_keywordsLabels: 6,
|
|
312
|
+
_keywordsOther: 3,
|
|
313
|
+
_keywordsPublisher: 3,
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
]])
|
|
317
|
+
}
|
|
318
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { validate } from "jskos-validate"
|
|
2
|
+
import jskos from "jskos-tools"
|
|
3
|
+
import { SchemeService } from "./schemes.js"
|
|
4
|
+
const guessObjectType = jskos.guessObjectType
|
|
5
|
+
|
|
6
|
+
import { AbstractService } from "./abstract.js"
|
|
7
|
+
|
|
8
|
+
export class ValidateService extends AbstractService {
|
|
9
|
+
|
|
10
|
+
constructor(config) {
|
|
11
|
+
super(config)
|
|
12
|
+
this.schemeService = new SchemeService(config)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async validate(data, { unknownFields, type, knownSchemes = false } = {}) {
|
|
16
|
+
if (!Array.isArray(data)) {
|
|
17
|
+
data = [data]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// additional parameters (optional)
|
|
21
|
+
type = (guessObjectType(type, true) || "").toLowerCase()
|
|
22
|
+
|
|
23
|
+
const rememberSchemes = type ? null : []
|
|
24
|
+
if (knownSchemes) {
|
|
25
|
+
// Get schemes from schemeService
|
|
26
|
+
knownSchemes = await this.schemeService.queryItems({})
|
|
27
|
+
type = "concept"
|
|
28
|
+
}
|
|
29
|
+
const validator = type ? validate[type] : validate
|
|
30
|
+
|
|
31
|
+
const result = data.map(item => {
|
|
32
|
+
const result = validator(item, { unknownFields, knownSchemes, rememberSchemes })
|
|
33
|
+
return result ? true : validator.errors
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
return result
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
}
|