@paulhectork/aiiinotate 0.12.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/LICENSE +661 -0
- package/README.md +206 -0
- package/cli/export.js +118 -0
- package/cli/import.js +111 -0
- package/cli/index.js +45 -0
- package/cli/migrate.js +131 -0
- package/cli/serve.js +29 -0
- package/cli/utils/fastifyClient.js +86 -0
- package/cli/utils/io.js +225 -0
- package/cli/utils/mongoClient.js +21 -0
- package/cli/utils/progressbar.js +86 -0
- package/cli/xywhToInt.js +99 -0
- package/config/.env.template +48 -0
- package/docker/Dockerfile +39 -0
- package/docker/README.md +5 -0
- package/docker/docker-compose.yaml +49 -0
- package/docker/docker.sh +43 -0
- package/docker/docker_aiiinotate_import.sh +62 -0
- package/docs/api.md +381 -0
- package/docs/cli.md +132 -0
- package/docs/dev_documentation/dev_architecture.md +88 -0
- package/docs/dev_documentation/dev_db.md +58 -0
- package/docs/dev_documentation/dev_iiif_compatibility.md +43 -0
- package/docs/dev_documentation/dev_notes_quirks_and_troubleshooting.md +143 -0
- package/docs/docker.md +98 -0
- package/docs/includes/report_benchmark_aiiinotate_2026-05-28-02:50:48_7steps.png +0 -0
- package/docs/scalability.md +34 -0
- package/docs/specifications/0_w3c_open_annotations.md +332 -0
- package/docs/specifications/1_w3c_web_annotations.md +577 -0
- package/docs/specifications/2_iiif_apis.md +428 -0
- package/docs/specifications/3_iiif_annotations.md +103 -0
- package/docs/specifications/4_search_api.md +135 -0
- package/docs/specifications/5_sas.md +119 -0
- package/docs/specifications/6_mirador.md +119 -0
- package/docs/specifications/7_aikon.md +137 -0
- package/docs/specifications/include/presentation_2.0.webp +0 -0
- package/docs/specifications/include/presentation_2.0_white.png +0 -0
- package/docs/specifications/include/presentation_3.0.png +0 -0
- package/docs/specifications/include/presentation_3.0_resize.png +0 -0
- package/eslint.config.js +30 -0
- package/migrations/baseConfig.js +57 -0
- package/migrations/manageIndex.js +55 -0
- package/migrations/migrate-mongo-config-main.js +8 -0
- package/migrations/migrate-mongo-config-test.js +8 -0
- package/migrations/migrationScripts/20250825185706-collections.js +48 -0
- package/migrations/migrationScripts/20250826194832-annotations2-schema.js +42 -0
- package/migrations/migrationScripts/20250904080710-annotations2-indexes.js +69 -0
- package/migrations/migrationScripts/20251002141951-manifests2-schema.js +43 -0
- package/migrations/migrationScripts/20251006212110-manifests2-indexes.js +35 -0
- package/migrations/migrationTemplate.js +25 -0
- package/package.json +82 -0
- package/scripts/get_version.py +12 -0
- package/scripts/run.sh +36 -0
- package/scripts/setup_mongodb.sh +70 -0
- package/scripts/setup_node.sh +15 -0
- package/scripts/update_version.py +30 -0
- package/scripts/utils.sh +65 -0
- package/src/app.js +116 -0
- package/src/constants.js +73 -0
- package/src/data/annotations/annotations2.js +681 -0
- package/src/data/annotations/annotations3.js +28 -0
- package/src/data/annotations/routes.js +335 -0
- package/src/data/annotations/routes.test.js +271 -0
- package/src/data/collectionAbstract.js +283 -0
- package/src/data/index.js +29 -0
- package/src/data/manifests/manifests2.js +378 -0
- package/src/data/manifests/manifests2.test.js +53 -0
- package/src/data/manifests/manifests3.js +23 -0
- package/src/data/manifests/routes.js +122 -0
- package/src/data/manifests/routes.test.js +70 -0
- package/src/data/routes.js +181 -0
- package/src/data/routes.test.js +166 -0
- package/src/db/index.js +50 -0
- package/src/fixtures/annotations.js +41 -0
- package/src/fixtures/data/annotationList_aikon_wit9_man11_anno165_all.jsonld +827 -0
- package/src/fixtures/data/annotationList_vhs_wit250_man250_anno250_all.jsonld +37514 -0
- package/src/fixtures/data/annotationList_vhs_wit253_man253_anno253_all.jsonld +20111 -0
- package/src/fixtures/data/annotations2Invalid.jsonld +39 -0
- package/src/fixtures/data/annotations2SvgValid.jsonld +81 -0
- package/src/fixtures/data/annotations2Valid.jsonld +39 -0
- package/src/fixtures/data/bnf_invalid_manifest.json +2806 -0
- package/src/fixtures/data/bnf_valid_manifest.json +2817 -0
- package/src/fixtures/data/vhs_wit253_man253_anno253_anno-24.json +1 -0
- package/src/fixtures/generate.js +181 -0
- package/src/fixtures/index.js +69 -0
- package/src/fixtures/manifests.js +14 -0
- package/src/fixtures/utils.js +37 -0
- package/src/schemas/index.js +100 -0
- package/src/schemas/schemasBase.js +19 -0
- package/src/schemas/schemasPresentation2.js +410 -0
- package/src/schemas/schemasPresentation3.js +33 -0
- package/src/schemas/schemasResolver.js +71 -0
- package/src/schemas/schemasRoutes.js +318 -0
- package/src/server.js +25 -0
- package/src/types.js +97 -0
- package/src/utils/iiif2Utils.js +332 -0
- package/src/utils/iiif2Utils.test.js +146 -0
- package/src/utils/iiif3Utils.js +0 -0
- package/src/utils/iiifUtils.js +18 -0
- package/src/utils/logger.js +119 -0
- package/src/utils/routeUtils.js +137 -0
- package/src/utils/svg.js +417 -0
- package/src/utils/testUtils.js +289 -0
- package/src/utils/utils.js +403 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
|
|
3
|
+
import build from "#src/app.js";
|
|
4
|
+
import { PORT, HOST } from "#constants";
|
|
5
|
+
import { assertObjectKeysInsert } from "#utils/testUtils.js";
|
|
6
|
+
|
|
7
|
+
/** @typedef {import("#types").NodeTestType} NodeTestType */
|
|
8
|
+
/** @typedef {import("#types").FastifyInstanceType} FastifyInstanceType */
|
|
9
|
+
/** @typedef {import("#types").FastifyReplyType} FastifyReplyType */
|
|
10
|
+
/** @typedef {import("#types").DataOperationsType} DataOperationsType */
|
|
11
|
+
|
|
12
|
+
test("test Manifests2 module", async (t) => {
|
|
13
|
+
const
|
|
14
|
+
fastify = await build("test"),
|
|
15
|
+
{
|
|
16
|
+
manifest2Valid,
|
|
17
|
+
manifest2ValidUri,
|
|
18
|
+
manifest2Invalid,
|
|
19
|
+
manifest2InvalidUri
|
|
20
|
+
} = fastify.fixtures;
|
|
21
|
+
|
|
22
|
+
await fastify.ready();
|
|
23
|
+
// close the app after running the tests
|
|
24
|
+
t.after(async () => await fastify.close());
|
|
25
|
+
// after each subtest has run, delete all database records
|
|
26
|
+
t.afterEach(async() => fastify.emptyCollections());
|
|
27
|
+
|
|
28
|
+
// NOTE: it is necessary to run the app because internally there are fetches to external data.
|
|
29
|
+
try {
|
|
30
|
+
await fastify.listen({ port: PORT, host: HOST });
|
|
31
|
+
} catch (err) {
|
|
32
|
+
console.log("FASTIFY ERROR", err);
|
|
33
|
+
throw err;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
await t.test("test Manifests2.insertManifest", async (t) => {
|
|
37
|
+
const r = await fastify.manifests2.insertManifest(manifest2Valid);
|
|
38
|
+
assertObjectKeysInsert(t, r);
|
|
39
|
+
|
|
40
|
+
// insertion should fail. since we are not inserting through HTTPs, we can't test error response keys
|
|
41
|
+
await t.assert.rejects(fastify.manifests2.insertManifest(manifest2Invalid));
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
await t.test("test Manifests2.insertManifestFromUri", async (t) => {
|
|
45
|
+
const r = await fastify.manifests2.insertManifestFromUri(manifest2ValidUri.uri);
|
|
46
|
+
assertObjectKeysInsert(t, r);
|
|
47
|
+
|
|
48
|
+
// insertion should fail. since we are not inserting through HTTPs, we can't test error response keys
|
|
49
|
+
await t.assert.rejects(fastify.manifests2.insertManifest(manifest2InvalidUri.uri));
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
return
|
|
53
|
+
})
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import fastifyPlugin from "fastify-plugin";
|
|
2
|
+
|
|
3
|
+
import CollectionAbstract from "#data/collectionAbstract.js";
|
|
4
|
+
|
|
5
|
+
/** @typedef {import("#types").FastifyInstanceType} FastifyInstanceType */
|
|
6
|
+
|
|
7
|
+
/** @typedef {Manifests3} Manifests3InstanceType */
|
|
8
|
+
|
|
9
|
+
class Manifests3 extends CollectionAbstract {
|
|
10
|
+
/**
|
|
11
|
+
* @param {FastifyInstanceType} fastify
|
|
12
|
+
*/
|
|
13
|
+
constructor(fastify) {
|
|
14
|
+
super(fastify, "manifests3");
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default fastifyPlugin((fastify, options, done) => {
|
|
19
|
+
fastify.decorate("manifests3", new Manifests3(fastify));
|
|
20
|
+
done();
|
|
21
|
+
}, {
|
|
22
|
+
name: "manifests3",
|
|
23
|
+
})
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import fastifyPlugin from "fastify-plugin";
|
|
2
|
+
|
|
3
|
+
import { makeResponsePostSchema, returnError, makeResponseSchema } from "#utils/routeUtils.js";
|
|
4
|
+
import { objectHasKey, getFirstNonEmptyPair, visibleLog } from "#utils/utils.js";
|
|
5
|
+
|
|
6
|
+
/** @typedef {import("#types").FastifyInstanceType} FastifyInstanceType */
|
|
7
|
+
/** @typedef {import("#types").Manifests2InstanceType} Manifests2InstanceType */
|
|
8
|
+
/** @typedef {import("#types").Manifests3InstanceType} Manifests3InstanceType */
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {FastifyInstanceType} fastify
|
|
12
|
+
* @param {object} options
|
|
13
|
+
* @param {Function} done
|
|
14
|
+
*/
|
|
15
|
+
function manifestsRoutes(fastify, options, done) {
|
|
16
|
+
const
|
|
17
|
+
/** @type {Manifests2InstanceType} */
|
|
18
|
+
manifests2 = fastify.manifests2,
|
|
19
|
+
/** @type {Manifests3InstanceType} */
|
|
20
|
+
manifests3 = fastify.manifests3,
|
|
21
|
+
iiifPresentationVersionSchema = fastify.schemasBase.getSchema("presentation"),
|
|
22
|
+
responsePostSchema = makeResponsePostSchema(fastify),
|
|
23
|
+
collectionSchema = makeResponseSchema(fastify, fastify.schemasPresentation2.getSchema("collection"));
|
|
24
|
+
|
|
25
|
+
///////////////////////////////////////////////
|
|
26
|
+
// read routes
|
|
27
|
+
|
|
28
|
+
fastify.get(
|
|
29
|
+
"/manifests/:iiifPresentationVersion",
|
|
30
|
+
{
|
|
31
|
+
schema: {
|
|
32
|
+
params: {
|
|
33
|
+
type: "object",
|
|
34
|
+
properties: {
|
|
35
|
+
iiifPresentationVersion: iiifPresentationVersionSchema
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
response: collectionSchema,
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
async (request, reply) => {
|
|
42
|
+
const { iiifPresentationVersion } = request.params;
|
|
43
|
+
try {
|
|
44
|
+
return iiifPresentationVersion === 2
|
|
45
|
+
? await manifests2.getManifests()
|
|
46
|
+
: manifests3.notImplementedError();
|
|
47
|
+
} catch (err) {
|
|
48
|
+
returnError(request, reply, err);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
fastify.get(
|
|
54
|
+
"/data/:iiifPresentationVersion/:manifestShortId/index",
|
|
55
|
+
{
|
|
56
|
+
schema: {
|
|
57
|
+
params: {
|
|
58
|
+
type: "object",
|
|
59
|
+
properties: {
|
|
60
|
+
iiifPresentationVersion: iiifPresentationVersionSchema,
|
|
61
|
+
manifestShortId: { type: "string" }
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
response: {
|
|
65
|
+
200: { type: "object" }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
async (request, reply) => {
|
|
70
|
+
const { iiifPresentationVersion, manifestShortId } = request.params;
|
|
71
|
+
try {
|
|
72
|
+
return iiifPresentationVersion === 2
|
|
73
|
+
? await manifests2.findByManifestShortId(manifestShortId)
|
|
74
|
+
: manifests3.notImplementedError();
|
|
75
|
+
} catch (err) {
|
|
76
|
+
returnError(request, reply, err);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
///////////////////////////////////////////////
|
|
82
|
+
// insert routes
|
|
83
|
+
|
|
84
|
+
fastify.post(
|
|
85
|
+
"/manifests/:iiifPresentationVersion/create",
|
|
86
|
+
{
|
|
87
|
+
schema: {
|
|
88
|
+
params: {
|
|
89
|
+
type: "object",
|
|
90
|
+
properties: {
|
|
91
|
+
iiifPresentationVersion: iiifPresentationVersionSchema
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
body: { $ref: fastify.schemasRoutes.getSchema("manifest2Or3") },
|
|
95
|
+
response: responsePostSchema
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
async (request, reply) => {
|
|
99
|
+
const
|
|
100
|
+
{ iiifPresentationVersion } = request.params,
|
|
101
|
+
manifestData = request.body;
|
|
102
|
+
try {
|
|
103
|
+
if (objectHasKey(manifestData, "uri")) {
|
|
104
|
+
return iiifPresentationVersion === 2
|
|
105
|
+
? await manifests2.insertManifestFromUri(manifestData.uri)
|
|
106
|
+
: manifests3.notImplementedError();
|
|
107
|
+
} else {
|
|
108
|
+
return iiifPresentationVersion === 2
|
|
109
|
+
? await manifests2.insertManifest(manifestData)
|
|
110
|
+
: manifests3.notImplementedError();
|
|
111
|
+
}
|
|
112
|
+
} catch (err) {
|
|
113
|
+
returnError(request, reply, err, request.body);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
done();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export default fastifyPlugin(manifestsRoutes);
|
|
122
|
+
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
|
|
3
|
+
import build from "#src/app.js";
|
|
4
|
+
import { visibleLog } from "#utils/utils.js";
|
|
5
|
+
import { getManifestShortId } from "#utils/iiif2Utils.js";
|
|
6
|
+
import { testPostRouteCurry, injectPost, injectTestManifest, assertStatusCode, assertObjectKeys } from "#utils/testUtils.js";
|
|
7
|
+
import { PORT, HOST } from "#constants";
|
|
8
|
+
|
|
9
|
+
/** @typedef {import("#types").FastifyInstanceType} FastifyInstanceType */
|
|
10
|
+
/** @typedef {import("#types").NodeTestType} NodeTestType */
|
|
11
|
+
|
|
12
|
+
test("test manifests Routes", async (t) => {
|
|
13
|
+
const
|
|
14
|
+
fastify = await build("test"),
|
|
15
|
+
testPostRoute = testPostRouteCurry(fastify),
|
|
16
|
+
testPostRouteCreate = testPostRoute("insert"),
|
|
17
|
+
testPostRouteCreateSuccess = testPostRouteCreate(true),
|
|
18
|
+
testPostRouteCreateFailure = testPostRouteCreate(false),
|
|
19
|
+
{
|
|
20
|
+
manifest2Valid,
|
|
21
|
+
manifest2Invalid,
|
|
22
|
+
manifest2ValidUri,
|
|
23
|
+
manifest2InvalidUri,
|
|
24
|
+
} = fastify.fixtures;
|
|
25
|
+
|
|
26
|
+
await fastify.ready();
|
|
27
|
+
// close the app after running the tests
|
|
28
|
+
t.after(async () => await fastify.close());
|
|
29
|
+
// after each subtest has run, delete all database records
|
|
30
|
+
t.afterEach(async() => fastify.emptyCollections());
|
|
31
|
+
|
|
32
|
+
// NOTE: it is necessary to run the app because internally there are fetches to external data.
|
|
33
|
+
try {
|
|
34
|
+
await fastify.listen({ port: PORT, host: HOST });
|
|
35
|
+
} catch (err) {
|
|
36
|
+
console.log("FASTIFY ERROR", err);
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
await t.test("test route /manifests/:iiifPresentationVersion/create", async (t) => {
|
|
41
|
+
const data = [
|
|
42
|
+
[[ manifest2Valid, manifest2ValidUri ], testPostRouteCreateSuccess ],
|
|
43
|
+
[[ manifest2Invalid, manifest2InvalidUri ], testPostRouteCreateFailure ]
|
|
44
|
+
];
|
|
45
|
+
for (let i=0; i<data.length; i++) {
|
|
46
|
+
const [ testData, func ] = data.at(i);
|
|
47
|
+
for (let j=0; j<testData.length; j++) {
|
|
48
|
+
const payload = testData.at(j);
|
|
49
|
+
await func(t, "/manifests/2/create", payload);
|
|
50
|
+
// for some reason, it is necessary to call `emptyCollections` explicitly here to avoid a JSONSchema validation error.
|
|
51
|
+
await fastify.emptyCollections();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
await t.test("test route /manifests/:iiifPresentationVersion/collection", async (t) => {
|
|
57
|
+
await injectTestManifest(fastify, t, manifest2Valid);
|
|
58
|
+
const r = await fastify.inject({
|
|
59
|
+
method: "GET",
|
|
60
|
+
url: "/manifests/2"
|
|
61
|
+
});
|
|
62
|
+
assertStatusCode(t, r, 200);
|
|
63
|
+
assertObjectKeys(
|
|
64
|
+
t,
|
|
65
|
+
JSON.parse(r.body),
|
|
66
|
+
[ "@context", "@id", "@type", "members", "label" ]
|
|
67
|
+
);
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
})
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import fastifyPlugin from "fastify-plugin"
|
|
2
|
+
|
|
3
|
+
import { pathToAiiinotatePublicUrl, ajvCompile, inspectObj, getFirstNonEmptyPair, visibleLog } from "#utils/utils.js";
|
|
4
|
+
import { returnError, makeResponseSchema, makeResponsePostSchema, addPagination } from "#utils/routeUtils.js";
|
|
5
|
+
|
|
6
|
+
/** @typedef {import("#types").Manifests2InstanceType} Manifests2InstanceType */
|
|
7
|
+
/** @typedef {import("#types").Manifests3InstanceType} Manifests3InstanceType */
|
|
8
|
+
/** @typedef {import("#types").Annotations2InstanceType} Annotations2InstanceType */
|
|
9
|
+
/** @typedef {import("#types").Annotations3InstanceType} Annotations3InstanceType */
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {FastifyInstanceType} fastify
|
|
13
|
+
* @param {object} options
|
|
14
|
+
* @param {Function} done
|
|
15
|
+
*/
|
|
16
|
+
function commonRoutes(fastify, options, done) {
|
|
17
|
+
const
|
|
18
|
+
/** @type {Annotations2InstanceType} */
|
|
19
|
+
annotations2 = fastify.annotations2,
|
|
20
|
+
/** @type {Annotations3InstanceType} */
|
|
21
|
+
annotations3 = fastify.annotations3,
|
|
22
|
+
/** @type {Manifests2InstanceType} */
|
|
23
|
+
manifests2 = fastify.manifests2,
|
|
24
|
+
/** @type {Manifests3InstanceType} */
|
|
25
|
+
manifests3 = fastify.manifests3,
|
|
26
|
+
iiifPresentationVersionSchema = fastify.schemasBase.getSchema("presentation"),
|
|
27
|
+
iiifSearchApiVersionSchema = fastify.schemasBase.getSchema("search"),
|
|
28
|
+
iiifAnnotationListSchema = fastify.schemasPresentation2.getSchema("annotationList"),
|
|
29
|
+
routeDeleteSchema = fastify.schemasRoutes.getSchema("routeDelete"),
|
|
30
|
+
responsePostSchema = makeResponsePostSchema(fastify),
|
|
31
|
+
validatorRouteAnnotationDeleteSchema = ajvCompile(fastify.schemasResolver(
|
|
32
|
+
fastify.schemasRoutes.getSchema("routeAnnotationDeleteFilter")
|
|
33
|
+
)),
|
|
34
|
+
validatorRouteManifestDeleteSchema = ajvCompile(fastify.schemasResolver(
|
|
35
|
+
fastify.schemasRoutes.getSchema("routeManifestFilter")
|
|
36
|
+
));
|
|
37
|
+
|
|
38
|
+
fastify.get(
|
|
39
|
+
"/search-api/:iiifSearchVersion/manifests/:manifestShortId/search",
|
|
40
|
+
{
|
|
41
|
+
schema: {
|
|
42
|
+
params: {
|
|
43
|
+
type: "object",
|
|
44
|
+
properties: {
|
|
45
|
+
iiifSearchVersion: iiifSearchApiVersionSchema,
|
|
46
|
+
manifestShortId: { type: "string" },
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
querystring: addPagination({
|
|
50
|
+
type: "object",
|
|
51
|
+
properties: {
|
|
52
|
+
q: { type: "string" },
|
|
53
|
+
motivation: {
|
|
54
|
+
type: "string",
|
|
55
|
+
enum: [ "painting", "non-painting", "commenting", "describing", "tagging", "linking" ]
|
|
56
|
+
},
|
|
57
|
+
canvasMin: {
|
|
58
|
+
type: "integer",
|
|
59
|
+
minimum: 0
|
|
60
|
+
},
|
|
61
|
+
canvasMax: {
|
|
62
|
+
type: [ "integer","null" ],
|
|
63
|
+
minimum: 0
|
|
64
|
+
},
|
|
65
|
+
onlyIds: {
|
|
66
|
+
type: "boolean",
|
|
67
|
+
default: "false"
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
}),
|
|
71
|
+
// return either an AnnotationList, or if `onlyIds=true`, an array of strings
|
|
72
|
+
response: makeResponseSchema(fastify, {
|
|
73
|
+
anyOf: [
|
|
74
|
+
{ type: "array", items: { type: "string" } },
|
|
75
|
+
// anyOf does not handle well $refs => resolve all references in the schema.
|
|
76
|
+
fastify.schemasResolver(iiifAnnotationListSchema)
|
|
77
|
+
]
|
|
78
|
+
})
|
|
79
|
+
// { 200: iiifAnnotationListSchema }
|
|
80
|
+
},
|
|
81
|
+
preValidation: async (request, reply) => {
|
|
82
|
+
const
|
|
83
|
+
{ canvasMin, canvasMax } = request.query,
|
|
84
|
+
error = new Error(`Error validating GET search-api route: 'canvasMin' must be smaller than 'canvasMax'. If 'canvasMax' is defined, 'canvasMin' must be defined as well. Got canvasMin=${canvasMin}, canvasMax=${canvasMax}`),
|
|
85
|
+
throwError = () => returnError(request, reply, error, {}, 400);
|
|
86
|
+
if (
|
|
87
|
+
(canvasMin == null && canvasMax != null)
|
|
88
|
+
|| (Number(canvasMax) < Number(canvasMin))
|
|
89
|
+
) {
|
|
90
|
+
throwError();
|
|
91
|
+
}
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
async (request, reply) => {
|
|
96
|
+
const
|
|
97
|
+
queryUrl = pathToAiiinotatePublicUrl(request.url),
|
|
98
|
+
{ iiifSearchVersion, manifestShortId } = request.params,
|
|
99
|
+
{ q, motivation, canvasMin, canvasMax, onlyIds, page, pageSize } = request.query;
|
|
100
|
+
|
|
101
|
+
if (iiifSearchVersion===1) {
|
|
102
|
+
return await annotations2.search({
|
|
103
|
+
queryUrl,
|
|
104
|
+
manifestShortId,
|
|
105
|
+
q,
|
|
106
|
+
motivation,
|
|
107
|
+
canvasMin,
|
|
108
|
+
canvasMax,
|
|
109
|
+
onlyIds,
|
|
110
|
+
page,
|
|
111
|
+
pageSize
|
|
112
|
+
});
|
|
113
|
+
} else {
|
|
114
|
+
annotations3.notImplementedError();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
/////////////////////////////////////////////
|
|
120
|
+
// DELETE routes
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* manifest and annotations work in the same manner, so we group them here.
|
|
124
|
+
* we add a custom `preHandler` to ensure that `queryString` follows the proper schema.
|
|
125
|
+
*/
|
|
126
|
+
fastify.delete(
|
|
127
|
+
"/:collectionName/:iiifPresentationVersion/delete",
|
|
128
|
+
{
|
|
129
|
+
schema: {
|
|
130
|
+
params: {
|
|
131
|
+
type: "object",
|
|
132
|
+
properties: {
|
|
133
|
+
collectionName: { type: "string", enum: [ "annotations", "manifests" ] },
|
|
134
|
+
iiifPresentationVersion: iiifPresentationVersionSchema
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
queryString: routeDeleteSchema,
|
|
138
|
+
response: responsePostSchema,
|
|
139
|
+
},
|
|
140
|
+
preValidation: async (request, reply) => {
|
|
141
|
+
// implement a custom validation hook: depending on the value of `collectionName`, run different schema validations.
|
|
142
|
+
const
|
|
143
|
+
{ collectionName } = request.params,
|
|
144
|
+
query = request.query,
|
|
145
|
+
validator =
|
|
146
|
+
collectionName==="annotations"
|
|
147
|
+
? validatorRouteAnnotationDeleteSchema
|
|
148
|
+
: validatorRouteManifestDeleteSchema,
|
|
149
|
+
error = new Error(`Error validating DELETE route on collection '${collectionName}' with queryString '${inspectObj(query)}'`);
|
|
150
|
+
if (!validator(query)) {
|
|
151
|
+
returnError(request, reply, error, {}, 400);
|
|
152
|
+
}
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
async (request, reply) => {
|
|
157
|
+
const { collectionName, iiifPresentationVersion } = request.params;
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
if (collectionName==="annotations") {
|
|
161
|
+
const deleteFilter = request.query;
|
|
162
|
+
return iiifPresentationVersion === 2
|
|
163
|
+
? await annotations2.deleteAnnotations(deleteFilter)
|
|
164
|
+
: annotations3.notImplementedError();
|
|
165
|
+
} else {
|
|
166
|
+
const [ deleteKey, deleteVal ] = getFirstNonEmptyPair(request.query);
|
|
167
|
+
return iiifPresentationVersion === 2
|
|
168
|
+
? await manifests2.deleteManifest(deleteKey, deleteVal)
|
|
169
|
+
: manifests3.notImplementedError();
|
|
170
|
+
}
|
|
171
|
+
} catch (err) {
|
|
172
|
+
returnError(request, reply, err, request.body);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
}
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
done();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export default fastifyPlugin(commonRoutes);
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
|
|
3
|
+
import { v4 as uuid4 } from "uuid";
|
|
4
|
+
|
|
5
|
+
import build from "#src/app.js";
|
|
6
|
+
import { getRandomItem, visibleLog } from "#utils/utils.js";
|
|
7
|
+
import { getManifestShortId } from "#utils/iiif2Utils.js";
|
|
8
|
+
import { testPostRouteCurry, testDeleteRouteCurry, injectTestManifest, injectTestAnnotations, assertErrorValidResponse, assertDeleteValidResponse, testGetPaginated } from "#utils/testUtils.js";
|
|
9
|
+
import { PORT, HOST } from "#constants";
|
|
10
|
+
|
|
11
|
+
/** @typedef {import("#types").FastifyInstanceType} FastifyInstanceType */
|
|
12
|
+
/** @typedef {import("#types").NodeTestType} NodeTestType */
|
|
13
|
+
|
|
14
|
+
test("test common routes", async (t) => {
|
|
15
|
+
const
|
|
16
|
+
/** @type {FastifyInstanceType} */
|
|
17
|
+
fastify = await build("test"),
|
|
18
|
+
testPostRoute = testPostRouteCurry(fastify),
|
|
19
|
+
testDeleteRoute = testDeleteRouteCurry(fastify),
|
|
20
|
+
{ manifest2Valid, annotationList } = fastify.fixtures;
|
|
21
|
+
|
|
22
|
+
await fastify.ready();
|
|
23
|
+
// close the app after running the tests
|
|
24
|
+
t.after(async () => await fastify.close());
|
|
25
|
+
// after each subtest has run, delete all database records
|
|
26
|
+
t.afterEach(async () => fastify.emptyCollections());
|
|
27
|
+
|
|
28
|
+
// NOTE: it is necessary to run the app because internally there are fetches to external data.
|
|
29
|
+
try {
|
|
30
|
+
await fastify.listen({ port: PORT, host: HOST });
|
|
31
|
+
} catch (err) {
|
|
32
|
+
console.log("FASTIFY ERROR", err);
|
|
33
|
+
throw err;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
////////////////////////////////////////////////
|
|
37
|
+
// GET routes
|
|
38
|
+
|
|
39
|
+
await t.test("test route /search-api/:iiifPresentationVersion/manifests/:manifestShortId/search", async (t) => {
|
|
40
|
+
await injectTestAnnotations(fastify, t, annotationList);
|
|
41
|
+
|
|
42
|
+
const manifestShortIdArray = [ ...new Set(
|
|
43
|
+
annotationList.resources.map(annotation => getManifestShortId(annotation.on))
|
|
44
|
+
) ];
|
|
45
|
+
let route, expectedCount, pageSize;
|
|
46
|
+
for (const manifestShortId of manifestShortIdArray) {
|
|
47
|
+
expectedCount = annotationList.resources.filter(annotation => annotation.on.includes(manifestShortId)).length;
|
|
48
|
+
pageSize = Math.max(Math.floor(expectedCount / 10), 1);
|
|
49
|
+
route = `/search-api/1/manifests/${manifestShortId}/search?pageSize=${pageSize}`;
|
|
50
|
+
await testGetPaginated(fastify, t, 2, route, expectedCount);
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
////////////////////////////////////////////////
|
|
55
|
+
// DELETE routes
|
|
56
|
+
|
|
57
|
+
await t.test("test route /:collectionName/:iiifPresentationVersion/delete", async (t) => {
|
|
58
|
+
|
|
59
|
+
await t.test("test preValidation hook for queryString validation", async (t) => {
|
|
60
|
+
const data = [
|
|
61
|
+
[ "/manifests/2/delete?canvasUri=xxx", false ], // canvasUri is only allowed if `collectionName==="annotations"` => will fail.
|
|
62
|
+
[ "/annotations/2/delete?tag=xxx", false ], // if using tag, manifestShortId must also be defined
|
|
63
|
+
[ "/manifests/2/delete?tag=xxx&manifestShortId=xxx", false ], // tag is not allowed with `manifests`
|
|
64
|
+
[ "/annotations/2/delete?tag=xxx&manifestShortId=xxx", true ],
|
|
65
|
+
[ "/manifests/2/delete?manifestShortId=xxx", true ],
|
|
66
|
+
];
|
|
67
|
+
for (let i=0; i<data.length; i++) {
|
|
68
|
+
const [ url, expectSuccess ] = data.at(i);
|
|
69
|
+
const r = await fastify.inject({
|
|
70
|
+
method: "DELETE",
|
|
71
|
+
url: url
|
|
72
|
+
})
|
|
73
|
+
expectSuccess
|
|
74
|
+
? assertDeleteValidResponse(t, r)
|
|
75
|
+
: assertErrorValidResponse(t, r, 400);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
await t.test("test route /manifests/:iiifPresentationVersion/delete", async (t) => {
|
|
80
|
+
const
|
|
81
|
+
manifest = manifest2Valid,
|
|
82
|
+
deleteQuery = [
|
|
83
|
+
[ "uri", manifest["@id"] ],
|
|
84
|
+
[ "manifestShortId", getManifestShortId(manifest["@id"]) ]
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
for (let i=0; i<deleteQuery.length; i++) {
|
|
88
|
+
const [ deleteBy, deleteKey ] = deleteQuery.at(i);
|
|
89
|
+
await injectTestManifest(fastify, t, manifest);
|
|
90
|
+
await testDeleteRoute(t, `/manifests/2/delete?${deleteBy}=${deleteKey}`, 1);
|
|
91
|
+
await fastify.emptyCollections();
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await t.test("test route /annotations/:iiifPresentationVersion/delete", async (t) => {
|
|
96
|
+
const deletePipeline = async (validFilter) =>
|
|
97
|
+
// validFilter is true => delete data that exists in the db (test that deletions are done correctly),
|
|
98
|
+
// validFilter is false => delete data that doesn't exist (test that nothing is deleted by accident)
|
|
99
|
+
await Promise.all(
|
|
100
|
+
// all 3 possible ways to delete data
|
|
101
|
+
[ "manifestShortId", "canvasUri", "uri" ].map(
|
|
102
|
+
async (deleteBy) =>
|
|
103
|
+
await t.test(`validFilter: ${validFilter}, deleteBy: ${deleteBy}`, async (t) => {
|
|
104
|
+
|
|
105
|
+
await injectTestAnnotations(fastify, t, annotationList);
|
|
106
|
+
const
|
|
107
|
+
annotations = await fastify.mongo.db.collection("annotations2").find({}).toArray(),
|
|
108
|
+
deleteKey =
|
|
109
|
+
validFilter
|
|
110
|
+
? deleteBy==="uri"
|
|
111
|
+
? getRandomItem(annotations.map((a) => a["@id"]))
|
|
112
|
+
: deleteBy==="canvasUri"
|
|
113
|
+
? getRandomItem(annotations.map((a) => getRandomItem(a.on).full))
|
|
114
|
+
: getRandomItem(annotations.map((a) => getRandomItem(a.on).manifestShortId))
|
|
115
|
+
: `invalid-filter-${uuid4()}`,
|
|
116
|
+
expectedDeletedCount =
|
|
117
|
+
validFilter
|
|
118
|
+
? deleteBy==="uri"
|
|
119
|
+
? annotations.filter((a) => a["@id"]===deleteKey).length
|
|
120
|
+
: deleteBy==="canvasUri"
|
|
121
|
+
? annotations.filter((a) => a.on.some((x) => x.full===deleteKey)).length
|
|
122
|
+
: annotations.filter((a) => a.on.some((x) => x.manifestShortId===deleteKey)).length
|
|
123
|
+
: 0;
|
|
124
|
+
|
|
125
|
+
await testDeleteRoute(t, `/annotations/2/delete?${deleteBy}=${deleteKey}`, expectedDeletedCount);
|
|
126
|
+
})
|
|
127
|
+
)
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
await deletePipeline(true);
|
|
131
|
+
await deletePipeline(false);
|
|
132
|
+
await fastify.emptyCollections();
|
|
133
|
+
|
|
134
|
+
await t.test("test route /annotations/:iiifPresentationVersion/delete with param 'tag'", async (t) => {
|
|
135
|
+
const
|
|
136
|
+
tags = [ "tag1", "tag2", "tag3" ],
|
|
137
|
+
// outputs one of the tags at random
|
|
138
|
+
selectTag = () => tags[Math.floor(Math.random() * 3)],
|
|
139
|
+
testTag = selectTag(),
|
|
140
|
+
// avoid changes to the global annotationList by cloning it.
|
|
141
|
+
annotationListCopy = structuredClone(annotationList),
|
|
142
|
+
// all annotations in the anno list are on the same manifest
|
|
143
|
+
manifestShortId = getManifestShortId(annotationList.resources[0].on);
|
|
144
|
+
|
|
145
|
+
annotationListCopy.resources = annotationList.resources.map((anno) => {
|
|
146
|
+
anno.resource = {
|
|
147
|
+
"@type": "oa:Tag",
|
|
148
|
+
"chars": selectTag()
|
|
149
|
+
}
|
|
150
|
+
return anno;
|
|
151
|
+
})
|
|
152
|
+
const expectedDeletedCount = annotationListCopy.resources.filter((anno) =>
|
|
153
|
+
anno.resource["@type"]==="oa:Tag"
|
|
154
|
+
&& anno.resource["chars"]===testTag
|
|
155
|
+
).length;
|
|
156
|
+
await injectTestAnnotations(fastify, t, annotationList);
|
|
157
|
+
await testDeleteRoute(t, `/annotations/2/delete?manifestShortId=${manifestShortId}&tag=${testTag}`, expectedDeletedCount);
|
|
158
|
+
})
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
return;
|
|
165
|
+
})
|
|
166
|
+
|
package/src/db/index.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import fastifyPlugin from "fastify-plugin"
|
|
2
|
+
import fastifyMongo from "@fastify/mongodb"
|
|
3
|
+
|
|
4
|
+
import { MONGODB_DB, MONGODB_DB_TEST, MONGODB_CONNSTRING, MONGODB_CONNSTRING_TEST } from "#constants";
|
|
5
|
+
|
|
6
|
+
/** @typedef {import('fastify').FastifyInstance} FastifyInstance */
|
|
7
|
+
/** @typedef {import('mongodb').Db} MongoDB */
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* empty all collections. can only be used on the test database for obvious reasons.
|
|
11
|
+
* @param {MongoDB} db
|
|
12
|
+
*/
|
|
13
|
+
const emptyCollections = async (db) => {
|
|
14
|
+
if (db.databaseName !== MONGODB_DB_TEST) {
|
|
15
|
+
throw new Error(`'emptyCollections' may only be used on test database defined by .env variable 'MONGODB_DB_TEST'. expected test database '${MONGODB_DB_TEST}' but working on database '${db.databaseName}'`);
|
|
16
|
+
}
|
|
17
|
+
await Promise.all(
|
|
18
|
+
[
|
|
19
|
+
"annotations3",
|
|
20
|
+
"annotations2",
|
|
21
|
+
"manifests3",
|
|
22
|
+
"manifests2"
|
|
23
|
+
].map(async (collectionName) =>
|
|
24
|
+
await db.collection(collectionName).deleteMany({})
|
|
25
|
+
)
|
|
26
|
+
)
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {FastifyInstance} fastify
|
|
31
|
+
* @param {Object} options
|
|
32
|
+
*/
|
|
33
|
+
async function dbConnector(fastify, options) {
|
|
34
|
+
const connString =
|
|
35
|
+
options.test
|
|
36
|
+
? MONGODB_CONNSTRING_TEST
|
|
37
|
+
: MONGODB_CONNSTRING;
|
|
38
|
+
|
|
39
|
+
await fastify.register(fastifyMongo, {
|
|
40
|
+
forceClose: true,
|
|
41
|
+
url: connString
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
if (options.test) {
|
|
45
|
+
fastify.decorate("emptyCollections", () => emptyCollections(fastify.mongo.db));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// wrapping a plugin function with fastify-plugin exposes what is declared inside the plugin to the parent scope.
|
|
50
|
+
export default fastifyPlugin(dbConnector)
|