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
package/test/validate.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// Tests for /validate endpoints
|
|
2
|
+
|
|
3
|
+
import chai from "./chai.js"
|
|
4
|
+
import * as server from "../server.js"
|
|
5
|
+
import { globSync } from "glob"
|
|
6
|
+
import fs from "node:fs"
|
|
7
|
+
import assert from "node:assert"
|
|
8
|
+
import { assertMongoDB, dropDatabaseBeforeAndAfter, setupInMemoryMongo, createCollectionsAndIndexes, teardownInMemoryMongo } from "./test-utils.js"
|
|
9
|
+
|
|
10
|
+
let types = ["resource", "item", "concept", "scheme", "mapping", "concordance", "registry", "distributions", "occurrence", "bundle", "annotation"]
|
|
11
|
+
let examples = {}
|
|
12
|
+
|
|
13
|
+
// Import local examples
|
|
14
|
+
for (let type of types) {
|
|
15
|
+
examples[type] = []
|
|
16
|
+
for (let expected of [true, false]) {
|
|
17
|
+
let files = globSync(`./node_modules/jskos-validate/examples/${type}/${expected ? "pass" : "fail"}/*.json`)
|
|
18
|
+
for (let file of files) {
|
|
19
|
+
try {
|
|
20
|
+
let object = JSON.parse(fs.readFileSync(file))
|
|
21
|
+
examples[type].push({
|
|
22
|
+
object,
|
|
23
|
+
expected,
|
|
24
|
+
file,
|
|
25
|
+
})
|
|
26
|
+
} catch(error) {
|
|
27
|
+
console.log("Unable to parse file", file)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("Validation endpoint testing", () => {
|
|
34
|
+
before(async () => {
|
|
35
|
+
const mongoUri = await setupInMemoryMongo({ replSet: false })
|
|
36
|
+
process.env.MONGO_URI = mongoUri
|
|
37
|
+
await createCollectionsAndIndexes()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
after(async () => {
|
|
41
|
+
// close server if you started one
|
|
42
|
+
await teardownInMemoryMongo()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
// 🗑 Drop DB before *and* after every single `it()` in this file
|
|
46
|
+
dropDatabaseBeforeAndAfter()
|
|
47
|
+
|
|
48
|
+
// 🔌 Sanity‐check that mongoose really is connected
|
|
49
|
+
assertMongoDB()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
describe("Validation endpoint: jskos-validate tests", () => {
|
|
53
|
+
|
|
54
|
+
// Validate difference object types
|
|
55
|
+
for (let type of types) {
|
|
56
|
+
let typePlural =
|
|
57
|
+
type.endsWith("y") && !/[aeiou]y$/i.test(type)
|
|
58
|
+
? type.slice(0, -1) + "ies"
|
|
59
|
+
: type + "s"
|
|
60
|
+
describe(typePlural, () => {
|
|
61
|
+
for (let { object, expected, file } of examples[type]) {
|
|
62
|
+
it(`should validate ${typePlural} (${file})`, done => {
|
|
63
|
+
// Support for arrays of objects
|
|
64
|
+
let objects = [object]
|
|
65
|
+
if (Array.isArray(object)) {
|
|
66
|
+
objects = object
|
|
67
|
+
}
|
|
68
|
+
for (let object of objects) {
|
|
69
|
+
chai.request.execute(server.app)
|
|
70
|
+
.post("/validate")
|
|
71
|
+
.query({
|
|
72
|
+
type,
|
|
73
|
+
})
|
|
74
|
+
.send(object)
|
|
75
|
+
.end((error, res) => {
|
|
76
|
+
assert.equal(error, null)
|
|
77
|
+
res.should.have.status(201)
|
|
78
|
+
res.body.should.be.an("array")
|
|
79
|
+
if (expected) {
|
|
80
|
+
assert.strictEqual(res.body[0], true)
|
|
81
|
+
} else {
|
|
82
|
+
res.body[0].should.be.an("array")
|
|
83
|
+
assert.notEqual(res.body[0].length, 0)
|
|
84
|
+
}
|
|
85
|
+
done()
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe("Validation endpoint: parameters", () => {
|
|
95
|
+
|
|
96
|
+
it("should validate empty object without type parameter", done => {
|
|
97
|
+
chai.request.execute(server.app)
|
|
98
|
+
.post("/validate")
|
|
99
|
+
.send({})
|
|
100
|
+
.end((error, res) => {
|
|
101
|
+
assert.equal(error, null)
|
|
102
|
+
res.should.have.status(201)
|
|
103
|
+
res.body.should.be.an("array")
|
|
104
|
+
assert.equal(res.body[0], true)
|
|
105
|
+
done()
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it("should fail validation for object with unknown parameter, but pass when `unknownFields` is set", done => {
|
|
110
|
+
const object = { abcdef: 1 }
|
|
111
|
+
chai.request.execute(server.app)
|
|
112
|
+
.post("/validate?type=concept")
|
|
113
|
+
.send(object)
|
|
114
|
+
.end((error, res) => {
|
|
115
|
+
assert.equal(error, null)
|
|
116
|
+
res.should.have.status(201)
|
|
117
|
+
res.body.should.be.an("array")
|
|
118
|
+
res.body[0].should.be.an("array")
|
|
119
|
+
assert.notEqual(res.body[0].length, 0)
|
|
120
|
+
|
|
121
|
+
// Set parameter
|
|
122
|
+
chai.request.execute(server.app)
|
|
123
|
+
.post("/validate")
|
|
124
|
+
.query({
|
|
125
|
+
unknownFields: true,
|
|
126
|
+
})
|
|
127
|
+
.send(object)
|
|
128
|
+
.end((error, res) => {
|
|
129
|
+
assert.equal(error, null)
|
|
130
|
+
res.should.have.status(201)
|
|
131
|
+
res.body.should.be.an("array")
|
|
132
|
+
assert.equal(res.body[0], true)
|
|
133
|
+
done()
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it("should remember validated schemes if no type is set", done => {
|
|
139
|
+
const objects = [
|
|
140
|
+
{
|
|
141
|
+
type: ["http://www.w3.org/2004/02/skos/core#ConceptScheme"],
|
|
142
|
+
uri: "http://example.org/voc",
|
|
143
|
+
notationPattern: "[a-z]+",
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
type: ["http://www.w3.org/2004/02/skos/core#Concept"],
|
|
147
|
+
uri: "http://example.org/1",
|
|
148
|
+
notation: ["abc"],
|
|
149
|
+
inScheme: [{uri: "http://example.org/voc"}],
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
type: ["http://www.w3.org/2004/02/skos/core#Concept"],
|
|
153
|
+
uri: "http://example.org/2",
|
|
154
|
+
notation: ["123"],
|
|
155
|
+
inScheme: [{uri: "http://example.org/voc"}],
|
|
156
|
+
},
|
|
157
|
+
]
|
|
158
|
+
chai.request.execute(server.app)
|
|
159
|
+
.post("/validate")
|
|
160
|
+
.send(objects)
|
|
161
|
+
.end((error, res) => {
|
|
162
|
+
assert.equal(error, null)
|
|
163
|
+
res.should.have.status(201)
|
|
164
|
+
res.body.should.be.an("array")
|
|
165
|
+
assert.equal(res.body.length, objects.length)
|
|
166
|
+
assert.equal(res.body[0], true)
|
|
167
|
+
assert.equal(res.body[1], true)
|
|
168
|
+
// Last concept should fail because notation does not match scheme's notationPattern
|
|
169
|
+
res.body[2].should.be.an("array")
|
|
170
|
+
assert.equal(res.body[2].length, 1)
|
|
171
|
+
done()
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it("should POST a scheme, then use that scheme's notationPattern to validate objects when `knownSchemes` is set", done => {
|
|
176
|
+
const scheme = {
|
|
177
|
+
type: ["http://www.w3.org/2004/02/skos/core#ConceptScheme"],
|
|
178
|
+
uri: "http://example.org/voc",
|
|
179
|
+
notationPattern: "[a-z]+",
|
|
180
|
+
}
|
|
181
|
+
const objects = [
|
|
182
|
+
{
|
|
183
|
+
uri: "http://example.org/1",
|
|
184
|
+
notation: ["abc"],
|
|
185
|
+
inScheme: [{ uri: scheme.uri }],
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
uri: "http://example.org/2",
|
|
189
|
+
notation: ["123"],
|
|
190
|
+
inScheme: [{ uri: scheme.uri }],
|
|
191
|
+
},
|
|
192
|
+
]
|
|
193
|
+
// 1. POST scheme
|
|
194
|
+
chai.request.execute(server.app)
|
|
195
|
+
.post("/voc")
|
|
196
|
+
.send(scheme)
|
|
197
|
+
.end((error, res) => {
|
|
198
|
+
assert.equal(error, null)
|
|
199
|
+
res.should.have.status(201)
|
|
200
|
+
res.body.should.be.an("object")
|
|
201
|
+
assert.equal(res.body.uri, scheme.uri)
|
|
202
|
+
// 2. Validate objects
|
|
203
|
+
chai.request.execute(server.app)
|
|
204
|
+
.post("/validate")
|
|
205
|
+
.query({
|
|
206
|
+
knownSchemes: true,
|
|
207
|
+
// type: concept is implied
|
|
208
|
+
})
|
|
209
|
+
.send(objects)
|
|
210
|
+
.end((error, res) => {
|
|
211
|
+
assert.equal(error, null)
|
|
212
|
+
res.should.have.status(201)
|
|
213
|
+
res.body.should.be.an("array")
|
|
214
|
+
// First concept should pass
|
|
215
|
+
assert.equal(res.body[0], true)
|
|
216
|
+
// Second concept should fail
|
|
217
|
+
res.body[1].should.be.an("array")
|
|
218
|
+
assert.notEqual(res.body[1].length, 0)
|
|
219
|
+
done()
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
})
|
package/utils/adjust.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import _ from "lodash"
|
|
2
|
+
import { AnnotationService } from "../services/annotations.js"
|
|
3
|
+
import { ConceptService } from "../services/concepts.js"
|
|
4
|
+
|
|
5
|
+
function createAdjuster(config) {
|
|
6
|
+
const services = {
|
|
7
|
+
annotations: new AnnotationService(config),
|
|
8
|
+
concepts: new ConceptService(config),
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Adjust data in req.data based on req.type (which is set by `addMiddlewareProperties`)
|
|
12
|
+
const adjust = async (req, res, next) => {
|
|
13
|
+
/**
|
|
14
|
+
* Skip adjustments if either:
|
|
15
|
+
* - there is no data
|
|
16
|
+
* - there is no data type (i.e. we don't know which adjustment method to use)
|
|
17
|
+
* - the request was a bulk operation
|
|
18
|
+
*/
|
|
19
|
+
if (!req.data || !req.type || req.query.bulk) {
|
|
20
|
+
next()
|
|
21
|
+
}
|
|
22
|
+
req.data = await adjust.data({ req })
|
|
23
|
+
next()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Wrapper around adjustments; `req` only has required property path `query.properties` if `data` and `type` are given.
|
|
27
|
+
adjust.data = async ({ req, data, type }) => {
|
|
28
|
+
data = data ?? req.data
|
|
29
|
+
type = type ?? req.type
|
|
30
|
+
// If data is still a mongoose object, convert it to plain object
|
|
31
|
+
if (Array.isArray(data) && data[0]?.toObject) {
|
|
32
|
+
data = data.map(item => item.toObject ? item.toObject() : item)
|
|
33
|
+
} else if (!Array.isArray(data) && data.toObject) {
|
|
34
|
+
data = data.toObject()
|
|
35
|
+
}
|
|
36
|
+
// Remove "s" from the end of type if it's not an array
|
|
37
|
+
if (!Array.isArray(data)) {
|
|
38
|
+
type = type.substring(0, type.length - 1)
|
|
39
|
+
}
|
|
40
|
+
if (adjust[type]) {
|
|
41
|
+
let addProperties = [], removeProperties = [], mode = 0 // mode 0 = add, mode 1 = remove
|
|
42
|
+
for (let prop of _.get(req, "query.properties", "").split(",")) {
|
|
43
|
+
if (prop.startsWith("*")) {
|
|
44
|
+
addProperties.push("narrower")
|
|
45
|
+
addProperties.push("ancestors")
|
|
46
|
+
addProperties.push("annotations")
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
if (prop.startsWith("-")) {
|
|
50
|
+
mode = 1
|
|
51
|
+
prop = prop.slice(1)
|
|
52
|
+
} else if (prop.startsWith("+")) {
|
|
53
|
+
mode = 0
|
|
54
|
+
prop = prop.slice(1)
|
|
55
|
+
}
|
|
56
|
+
if (mode === 1) {
|
|
57
|
+
removeProperties.push(prop)
|
|
58
|
+
} else {
|
|
59
|
+
addProperties.push(prop)
|
|
60
|
+
// If a property is explicitly added after it was removed, it should not be removed anymore
|
|
61
|
+
removeProperties = removeProperties.filter(p => p !== prop)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
addProperties = addProperties.filter(Boolean)
|
|
65
|
+
removeProperties = removeProperties.filter(Boolean)
|
|
66
|
+
// Adjust data with properties
|
|
67
|
+
data = await adjust[type](data, addProperties)
|
|
68
|
+
// Remove properties if necessary
|
|
69
|
+
const dataToAdjust = Array.isArray(data) ? data : [data]
|
|
70
|
+
removeProperties.forEach(property => {
|
|
71
|
+
dataToAdjust.filter(Boolean).forEach(entity => {
|
|
72
|
+
delete entity[property]
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
return data
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Add @context and type to annotations.
|
|
80
|
+
adjust.annotation = (annotation) => {
|
|
81
|
+
if (annotation) {
|
|
82
|
+
annotation["@context"] = "http://www.w3.org/ns/anno.jsonld"
|
|
83
|
+
annotation.type = "Annotation"
|
|
84
|
+
}
|
|
85
|
+
return annotation
|
|
86
|
+
}
|
|
87
|
+
adjust.annotations = annotations => {
|
|
88
|
+
return annotations.map(annotation => adjust.annotation(annotation))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Add @context and type to concepts. Also load properties narrower, ancestors, and annotations if necessary.
|
|
92
|
+
adjust.concept = async (concept, properties = []) => {
|
|
93
|
+
if (concept) {
|
|
94
|
+
concept["@context"] = "https://gbv.github.io/jskos/context.json"
|
|
95
|
+
concept.type = concept.type || ["http://www.w3.org/2004/02/skos/core#Concept"]
|
|
96
|
+
// Add properties (narrower, ancestors)
|
|
97
|
+
for (let property of ["narrower", "ancestors"].filter(p => properties.includes(p))) {
|
|
98
|
+
concept[property] = await Promise.all((await services.concepts[`get${property.charAt(0).toUpperCase() + property.slice(1)}`]({ uri: concept.uri })).map(concept => adjust.concept(concept)))
|
|
99
|
+
}
|
|
100
|
+
// Add properties (annotations)
|
|
101
|
+
if (config.annotations && properties.includes("annotations") && concept.uri) {
|
|
102
|
+
concept.annotations = (await services.annotations.queryItems({ target: concept.uri })).map(annotation => adjust.annotation(annotation))
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return concept
|
|
106
|
+
}
|
|
107
|
+
adjust.concepts = async (concepts, properties) => {
|
|
108
|
+
return await Promise.all(concepts.map(concept => adjust.concept(concept, properties)))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Add @context to concordances.
|
|
112
|
+
adjust.concordance = (concordance) => {
|
|
113
|
+
if (concordance) {
|
|
114
|
+
concordance["@context"] = "https://gbv.github.io/jskos/context.json"
|
|
115
|
+
// Remove existing "distributions" array (except for external URLs)
|
|
116
|
+
concordance.distributions = (concordance.distributions || []).filter(dist => !dist.download || !dist.download.startsWith(config.baseUrl))
|
|
117
|
+
// Add distributions for JSKOS and CSV
|
|
118
|
+
concordance.distributions = [
|
|
119
|
+
{
|
|
120
|
+
download: `${config.baseUrl}mappings?partOf=${encodeURIComponent(concordance.uri)}&download=ndjson`,
|
|
121
|
+
format: "http://format.gbv.de/jskos",
|
|
122
|
+
mimetype: "application/x-ndjson; charset=utf-8",
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
download: `${config.baseUrl}mappings?partOf=${encodeURIComponent(concordance.uri)}&download=csv`,
|
|
126
|
+
mimetype: "text/csv; charset=utf-8",
|
|
127
|
+
},
|
|
128
|
+
].concat(concordance.distributions)
|
|
129
|
+
}
|
|
130
|
+
return concordance
|
|
131
|
+
}
|
|
132
|
+
adjust.concordances = (concordances) => {
|
|
133
|
+
return concordances.map(concordance => adjust.concordance(concordance))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Add @context to mappings. Also load annotations if necessary.
|
|
137
|
+
adjust.mapping = async (mapping, properties = []) => {
|
|
138
|
+
if (mapping) {
|
|
139
|
+
mapping["@context"] = "https://gbv.github.io/jskos/context.json"
|
|
140
|
+
// Add properties (annotations)
|
|
141
|
+
if (config.annotations && properties.includes("annotations") && mapping.uri) {
|
|
142
|
+
mapping.annotations = (await services.annotations.queryItems({ target: mapping.uri })).map(annotation => adjust.annotation(annotation))
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return mapping
|
|
146
|
+
}
|
|
147
|
+
adjust.mappings = async (mappings, properties) => {
|
|
148
|
+
return await Promise.all(mappings.map(mapping => adjust.mapping(mapping, properties)))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Add @context and type to schemes.
|
|
152
|
+
adjust.scheme = (scheme) => {
|
|
153
|
+
if (scheme) {
|
|
154
|
+
scheme["@context"] = "https://gbv.github.io/jskos/context.json"
|
|
155
|
+
scheme.type = scheme.type || ["http://www.w3.org/2004/02/skos/core#ConceptScheme"]
|
|
156
|
+
// Remove existing "distributions" array (except for external URLs)
|
|
157
|
+
scheme.distributions = (scheme.distributions || []).filter(dist => !dist.download || !dist.download.startsWith(config.baseUrl))
|
|
158
|
+
if (scheme.concepts && scheme.concepts.length) {
|
|
159
|
+
// If this instance contains concepts for this scheme, add distribution for it
|
|
160
|
+
scheme.distributions = [
|
|
161
|
+
{
|
|
162
|
+
download: `${config.baseUrl}voc/concepts?uri=${encodeURIComponent(scheme.uri)}&download=ndjson`,
|
|
163
|
+
format: "http://format.gbv.de/jskos",
|
|
164
|
+
mimetype: "application/x-ndjson; charset=utf-8",
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
download: `${config.baseUrl}voc/concepts?uri=${encodeURIComponent(scheme.uri)}&download=json`,
|
|
168
|
+
mimetype: "application/json; charset=utf-8",
|
|
169
|
+
},
|
|
170
|
+
].concat(scheme.distributions)
|
|
171
|
+
// Also add `API` field if it does not exist
|
|
172
|
+
if (!scheme.API) {
|
|
173
|
+
scheme.API = [
|
|
174
|
+
{
|
|
175
|
+
type: "http://bartoc.org/api-type/jskos",
|
|
176
|
+
url: config.baseUrl,
|
|
177
|
+
},
|
|
178
|
+
]
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// Add distributions based on API field
|
|
182
|
+
(scheme.API || []).filter(api => api.type === "http://bartoc.org/api-type/jskos" && api.url !== config.baseUrl).forEach(api => {
|
|
183
|
+
scheme.distributions.push({
|
|
184
|
+
download: `${api.url}voc/concepts?uri=${encodeURIComponent(scheme.uri)}&download=ndjson`,
|
|
185
|
+
format: "http://format.gbv.de/jskos",
|
|
186
|
+
mimetype: "application/x-ndjson; charset=utf-8",
|
|
187
|
+
})
|
|
188
|
+
scheme.distributions.push({
|
|
189
|
+
download: `${api.url}voc/concepts?uri=${encodeURIComponent(scheme.uri)}&download=json`,
|
|
190
|
+
mimetype: "application/json; charset=utf-8",
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
if (!scheme.distributions.length) {
|
|
194
|
+
delete scheme.distributions
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return scheme
|
|
198
|
+
}
|
|
199
|
+
adjust.schemes = (schemes) => {
|
|
200
|
+
return schemes.map(scheme => adjust.scheme(scheme))
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return adjust
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export { createAdjuster }
|
package/utils/auth.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module that prepares authentication middleware via Passport.
|
|
3
|
+
*
|
|
4
|
+
* Exports a function that return default or optional authentication.
|
|
5
|
+
* Optional authentication should be used if `auth` is set to `false` for a particular endpoint.
|
|
6
|
+
* For example: app.get("/mappings", useAuth(config.mappings.read.auth), (req, res) => { ... })
|
|
7
|
+
* req.user will cointain the user if authorized, otherwise stays undefined.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import _ from "lodash"
|
|
11
|
+
import { ForbiddenAccessError } from "../errors/index.js"
|
|
12
|
+
|
|
13
|
+
import config from "../config/index.js"
|
|
14
|
+
|
|
15
|
+
import passport from "passport"
|
|
16
|
+
import { Strategy as JwtStrategy, ExtractJwt } from "passport-jwt"
|
|
17
|
+
import { Strategy as AnonymousStrategy } from "passport-anonymous"
|
|
18
|
+
|
|
19
|
+
passport.use(new AnonymousStrategy())
|
|
20
|
+
|
|
21
|
+
const actions = {
|
|
22
|
+
POST: "create",
|
|
23
|
+
PUT: "update",
|
|
24
|
+
PATCH: "update",
|
|
25
|
+
DELETE: "delete",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Add some properties and methods related to authentication
|
|
29
|
+
// This middleware is added to both auth.main and auth.optional
|
|
30
|
+
const authPreparation = (req, res, next) => {
|
|
31
|
+
// Add action
|
|
32
|
+
req.action = actions[req.method] || "read"
|
|
33
|
+
|
|
34
|
+
// Add user URIs and providers
|
|
35
|
+
req.uris = [req.user?.uri].concat(Object.values(req.user?.identities || {}).map(id => id.uri)).filter(Boolean)
|
|
36
|
+
req.userProviders = Object.keys(req.user?.identities || {})
|
|
37
|
+
|
|
38
|
+
// Add isAuthorizedFor method
|
|
39
|
+
req.isAuthorizedFor = function ({ type, action, whitelist, providers, throwError = false } = {}) {
|
|
40
|
+
type = type ?? this.type
|
|
41
|
+
action = action ?? this.action
|
|
42
|
+
|
|
43
|
+
if (!config[type]?.[action]?.auth && type !== "checkAuth") {
|
|
44
|
+
// If action does not require auth at all, the request is authorized
|
|
45
|
+
return true
|
|
46
|
+
} else if (!this.user) {
|
|
47
|
+
// If action requires auth, but user isn't logged in, the request is not authorized
|
|
48
|
+
// For routes using the `auth.main` middleware, this is called early, but not for routes with `auth.optional`.
|
|
49
|
+
if (throwError) {
|
|
50
|
+
throw new ForbiddenAccessError("Access forbidden. Could not authenticate via JWT.")
|
|
51
|
+
}
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (whitelist === undefined) {
|
|
56
|
+
whitelist = expandWhiteList(config[type]?.[action]?.identities, config.identityGroups)
|
|
57
|
+
}
|
|
58
|
+
providers = providers ?? config[type]?.[action]?.identityProviders
|
|
59
|
+
|
|
60
|
+
if (whitelist && _.intersection(whitelist, this.uris).length == 0) {
|
|
61
|
+
if (throwError) {
|
|
62
|
+
throw new ForbiddenAccessError("Access forbidden. A whitelist is in place, but authenticated user is not on the whitelist.")
|
|
63
|
+
}
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (providers && _.intersection(providers, this.userProviders).length == 0) {
|
|
68
|
+
if (throwError) {
|
|
69
|
+
throw new ForbiddenAccessError("Access forbidden, missing identity provider. One of the following providers is necessary: " + providers.join(", "))
|
|
70
|
+
}
|
|
71
|
+
return false
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return true
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
next()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function expandWhiteList(whitelist, identityGroups) {
|
|
81
|
+
if (whitelist && identityGroups) {
|
|
82
|
+
return whitelist.map(uri => uri in identityGroups ? identityGroups[uri].identities : uri).flat()
|
|
83
|
+
}
|
|
84
|
+
return whitelist
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let optional = []
|
|
88
|
+
let auth = (req, res, next) => {
|
|
89
|
+
next(new ForbiddenAccessError("Access forbidden. No authentication configured."))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Prepare authorization via JWT
|
|
93
|
+
if (config.auth.algorithm && config.auth.key) {
|
|
94
|
+
const opts = {
|
|
95
|
+
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
|
96
|
+
secretOrKey: config.auth.key,
|
|
97
|
+
algorithms: [config.auth.algorithm],
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
const strategy = new JwtStrategy(opts, (jwt_payload, done) => done(null, jwt_payload.user))
|
|
101
|
+
const strategyName = "jwt" // TODO: derive strategyName from opts
|
|
102
|
+
|
|
103
|
+
passport.use(strategyName, strategy)
|
|
104
|
+
optional.push(strategyName)
|
|
105
|
+
|
|
106
|
+
// Use like this: app.get("/secureEndpoint", auth, (req, res) => { ... })
|
|
107
|
+
// res.user will contain the current authorized user.
|
|
108
|
+
auth = (req, res, next) => {
|
|
109
|
+
passport.authenticate(strategyName, { session: false }, (error, user) => {
|
|
110
|
+
if (error || !user) {
|
|
111
|
+
return next(new ForbiddenAccessError("Access forbidden. Could not authenticate via JWT."))
|
|
112
|
+
}
|
|
113
|
+
req.user = user
|
|
114
|
+
return next()
|
|
115
|
+
})(req, res, next)
|
|
116
|
+
}
|
|
117
|
+
} catch(error) {
|
|
118
|
+
config.error("Error setting up JWT authentication")
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
config.warn("Note: To provide authentication via JWT, please add `auth.algorithm` and `auth.key` to the configuration file!")
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Configure identities whitelists and identity providers
|
|
125
|
+
const authAuthorize = (req, res, next) => {
|
|
126
|
+
let whitelist, providers, type, action
|
|
127
|
+
|
|
128
|
+
if (req.type == "checkAuth") {
|
|
129
|
+
({ type, action } = req.query || {})
|
|
130
|
+
if (type && action && config[type][action]) {
|
|
131
|
+
whitelist = config[type][action].identities
|
|
132
|
+
providers = config[type][action].identityProviders
|
|
133
|
+
} else {
|
|
134
|
+
whitelist = config.identities
|
|
135
|
+
providers = config.identityProviders
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
whitelist = expandWhiteList(whitelist, config.identityGroups)
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
req.isAuthorizedFor({ type, action, whitelist, providers, throwError: true })
|
|
143
|
+
next()
|
|
144
|
+
} catch (error) {
|
|
145
|
+
next(error)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Also use anonymous strategy for endpoints that can be used authenticated or not authenticated
|
|
150
|
+
optional.push("anonymous")
|
|
151
|
+
|
|
152
|
+
export const useAuth = required => required
|
|
153
|
+
? [auth, authPreparation, authAuthorize]
|
|
154
|
+
: [passport.authenticate(optional, { session: false }), authPreparation]
|
package/utils/changes.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// utils/changes.js
|
|
2
|
+
import * as jskos from "jskos-tools"
|
|
3
|
+
import { connection, waitForReplicaSet } from "./db.js"
|
|
4
|
+
import { ConfigurationError } from "../errors/index.js"
|
|
5
|
+
|
|
6
|
+
export const collections = {
|
|
7
|
+
voc: "terminologies",
|
|
8
|
+
concepts: "concepts",
|
|
9
|
+
mappings: "mappings",
|
|
10
|
+
concordances: "concordances",
|
|
11
|
+
annotations: "annotations",
|
|
12
|
+
registries: "registries",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const operationTypeMap = { insert: "create", update: "update", delete: "delete" }
|
|
16
|
+
export let isChangesApiAvailable = false
|
|
17
|
+
|
|
18
|
+
export default function registerChangesRoutes(app) {
|
|
19
|
+
for (const [route, collName] of Object.entries(collections)) {
|
|
20
|
+
app.ws(`/${route}/changes`, (ws) => {
|
|
21
|
+
const stream = connection.db
|
|
22
|
+
.collection(collName)
|
|
23
|
+
.watch([], { fullDocument: "updateLookup" })
|
|
24
|
+
|
|
25
|
+
stream.on("change", change => {
|
|
26
|
+
const { operationType, documentKey, fullDocument } = change
|
|
27
|
+
const evt = {
|
|
28
|
+
objectType: jskos.guessObjectType(fullDocument),
|
|
29
|
+
type: operationTypeMap[operationType],
|
|
30
|
+
id: documentKey._id,
|
|
31
|
+
timestamp: clusterTimeToISOString(change.clusterTime),
|
|
32
|
+
...(operationType !== "delete" && { document: fullDocument }),
|
|
33
|
+
}
|
|
34
|
+
ws.send(JSON.stringify(evt))
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
stream.on("error", (err) => {
|
|
38
|
+
if (err?.name === "MongoClientClosedError") {
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
console.error(
|
|
42
|
+
`[changes] ChangeStream error on "${route}" (${collName}):`,
|
|
43
|
+
err,
|
|
44
|
+
)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
ws.on("close", () => stream.close())
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// After DB connection, conditionally enable change-stream routes
|
|
53
|
+
export async function setupChangesApi(app, config) {
|
|
54
|
+
if (!config.changes) {
|
|
55
|
+
console.log("Changes API is disabled by configuration.")
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!await waitForReplicaSet(config.changes)) {
|
|
60
|
+
throw new ConfigurationError(
|
|
61
|
+
"Changes API enabled, but MongoDB replica set did not initialize in time.",
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Register WebSocket change-stream endpoints
|
|
66
|
+
registerChangesRoutes(app)
|
|
67
|
+
isChangesApiAvailable = true
|
|
68
|
+
console.log("Changes API enabled: replica set confirmed, endpoints are registered.")
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Converts a MongoDB cluster time object to an ISO 8601 string.
|
|
73
|
+
*
|
|
74
|
+
* @param {{ getHighBits: () => number } | null} clusterTime - The cluster time object.
|
|
75
|
+
* @returns {string | null} ISO timestamp derived from the cluster time, or null if input is invalid.
|
|
76
|
+
*/
|
|
77
|
+
function clusterTimeToISOString(clusterTime) {
|
|
78
|
+
if ((!clusterTime) || (typeof clusterTime.getHighBits !== "function")) {
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const seconds = clusterTime.getHighBits()
|
|
83
|
+
if (typeof seconds !== "number" || !Number.isFinite(seconds)) {
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return new Date(seconds * 1000).toISOString()
|
|
88
|
+
}
|