@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.
Files changed (104) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +206 -0
  3. package/cli/export.js +118 -0
  4. package/cli/import.js +111 -0
  5. package/cli/index.js +45 -0
  6. package/cli/migrate.js +131 -0
  7. package/cli/serve.js +29 -0
  8. package/cli/utils/fastifyClient.js +86 -0
  9. package/cli/utils/io.js +225 -0
  10. package/cli/utils/mongoClient.js +21 -0
  11. package/cli/utils/progressbar.js +86 -0
  12. package/cli/xywhToInt.js +99 -0
  13. package/config/.env.template +48 -0
  14. package/docker/Dockerfile +39 -0
  15. package/docker/README.md +5 -0
  16. package/docker/docker-compose.yaml +49 -0
  17. package/docker/docker.sh +43 -0
  18. package/docker/docker_aiiinotate_import.sh +62 -0
  19. package/docs/api.md +381 -0
  20. package/docs/cli.md +132 -0
  21. package/docs/dev_documentation/dev_architecture.md +88 -0
  22. package/docs/dev_documentation/dev_db.md +58 -0
  23. package/docs/dev_documentation/dev_iiif_compatibility.md +43 -0
  24. package/docs/dev_documentation/dev_notes_quirks_and_troubleshooting.md +143 -0
  25. package/docs/docker.md +98 -0
  26. package/docs/includes/report_benchmark_aiiinotate_2026-05-28-02:50:48_7steps.png +0 -0
  27. package/docs/scalability.md +34 -0
  28. package/docs/specifications/0_w3c_open_annotations.md +332 -0
  29. package/docs/specifications/1_w3c_web_annotations.md +577 -0
  30. package/docs/specifications/2_iiif_apis.md +428 -0
  31. package/docs/specifications/3_iiif_annotations.md +103 -0
  32. package/docs/specifications/4_search_api.md +135 -0
  33. package/docs/specifications/5_sas.md +119 -0
  34. package/docs/specifications/6_mirador.md +119 -0
  35. package/docs/specifications/7_aikon.md +137 -0
  36. package/docs/specifications/include/presentation_2.0.webp +0 -0
  37. package/docs/specifications/include/presentation_2.0_white.png +0 -0
  38. package/docs/specifications/include/presentation_3.0.png +0 -0
  39. package/docs/specifications/include/presentation_3.0_resize.png +0 -0
  40. package/eslint.config.js +30 -0
  41. package/migrations/baseConfig.js +57 -0
  42. package/migrations/manageIndex.js +55 -0
  43. package/migrations/migrate-mongo-config-main.js +8 -0
  44. package/migrations/migrate-mongo-config-test.js +8 -0
  45. package/migrations/migrationScripts/20250825185706-collections.js +48 -0
  46. package/migrations/migrationScripts/20250826194832-annotations2-schema.js +42 -0
  47. package/migrations/migrationScripts/20250904080710-annotations2-indexes.js +69 -0
  48. package/migrations/migrationScripts/20251002141951-manifests2-schema.js +43 -0
  49. package/migrations/migrationScripts/20251006212110-manifests2-indexes.js +35 -0
  50. package/migrations/migrationTemplate.js +25 -0
  51. package/package.json +82 -0
  52. package/scripts/get_version.py +12 -0
  53. package/scripts/run.sh +36 -0
  54. package/scripts/setup_mongodb.sh +70 -0
  55. package/scripts/setup_node.sh +15 -0
  56. package/scripts/update_version.py +30 -0
  57. package/scripts/utils.sh +65 -0
  58. package/src/app.js +116 -0
  59. package/src/constants.js +73 -0
  60. package/src/data/annotations/annotations2.js +681 -0
  61. package/src/data/annotations/annotations3.js +28 -0
  62. package/src/data/annotations/routes.js +335 -0
  63. package/src/data/annotations/routes.test.js +271 -0
  64. package/src/data/collectionAbstract.js +283 -0
  65. package/src/data/index.js +29 -0
  66. package/src/data/manifests/manifests2.js +378 -0
  67. package/src/data/manifests/manifests2.test.js +53 -0
  68. package/src/data/manifests/manifests3.js +23 -0
  69. package/src/data/manifests/routes.js +122 -0
  70. package/src/data/manifests/routes.test.js +70 -0
  71. package/src/data/routes.js +181 -0
  72. package/src/data/routes.test.js +166 -0
  73. package/src/db/index.js +50 -0
  74. package/src/fixtures/annotations.js +41 -0
  75. package/src/fixtures/data/annotationList_aikon_wit9_man11_anno165_all.jsonld +827 -0
  76. package/src/fixtures/data/annotationList_vhs_wit250_man250_anno250_all.jsonld +37514 -0
  77. package/src/fixtures/data/annotationList_vhs_wit253_man253_anno253_all.jsonld +20111 -0
  78. package/src/fixtures/data/annotations2Invalid.jsonld +39 -0
  79. package/src/fixtures/data/annotations2SvgValid.jsonld +81 -0
  80. package/src/fixtures/data/annotations2Valid.jsonld +39 -0
  81. package/src/fixtures/data/bnf_invalid_manifest.json +2806 -0
  82. package/src/fixtures/data/bnf_valid_manifest.json +2817 -0
  83. package/src/fixtures/data/vhs_wit253_man253_anno253_anno-24.json +1 -0
  84. package/src/fixtures/generate.js +181 -0
  85. package/src/fixtures/index.js +69 -0
  86. package/src/fixtures/manifests.js +14 -0
  87. package/src/fixtures/utils.js +37 -0
  88. package/src/schemas/index.js +100 -0
  89. package/src/schemas/schemasBase.js +19 -0
  90. package/src/schemas/schemasPresentation2.js +410 -0
  91. package/src/schemas/schemasPresentation3.js +33 -0
  92. package/src/schemas/schemasResolver.js +71 -0
  93. package/src/schemas/schemasRoutes.js +318 -0
  94. package/src/server.js +25 -0
  95. package/src/types.js +97 -0
  96. package/src/utils/iiif2Utils.js +332 -0
  97. package/src/utils/iiif2Utils.test.js +146 -0
  98. package/src/utils/iiif3Utils.js +0 -0
  99. package/src/utils/iiifUtils.js +18 -0
  100. package/src/utils/logger.js +119 -0
  101. package/src/utils/routeUtils.js +137 -0
  102. package/src/utils/svg.js +417 -0
  103. package/src/utils/testUtils.js +289 -0
  104. 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
+
@@ -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)