aiiinotate 0.3.2 → 0.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/docs/endpoints.md CHANGED
@@ -1,8 +1,250 @@
1
1
  # Endpoints
2
2
 
3
+ ## Introductory notes
4
+
5
+ **aiiinotate** is meant to be able to handle both IIIF presentation APIs: the most common [2.x](https://iiif.io/api/presentation/2.1) and the more recent [3.x](https://iiif.io/api/presentation/3.0). Both APIs define a data structure for manifests, annotations, lists of annotations and collections of manifests.
6
+
7
+ **HOWEVER, in aiiinotate, IIIF Presentation v2 and v3 data are isolated**: they form two separate collections, and no conversion is done between IIIF 2.x and 3.x data. This means that:
8
+ - **when communicating with aiiinotate**, you must specify a **IIIF presentation version in the query URL**. In the docs, this is described by the `iiif_version` keyword.
9
+ - **when inserting/updating data**, the data structure you provide must match the URL's `iiif_version`: you can't insert an annotation in v3 if your `iiif_version` is `2`.
10
+ - **when searching for data**, if you inserted an annotation in v3, you must search for it with `iiif_version = 3`.
11
+ - **TLDR**:
12
+ - your data must match the `iiif_version` argument
13
+ - if you insert an Annotation following the API v3.x, you can't search for it using `iiif_version=2`.
14
+
15
+ This is because
16
+ - the IIIF standard is quite complex and there are breaking changes between v2 and v3
17
+ - handling conversions between v2 and v3 is error prone, would increase calculations and slow the app down
18
+
19
+ ---
20
+
21
+ ## Generic routes
22
+
23
+ ### IIIF search API
24
+
25
+ ```
26
+ GET /search-api/{iiif_version}/manifests/{manifest_short_id}/search
27
+ ```
28
+
29
+ Implementation of the [IIIF Search API](https://iiif.io/api/search/2.0/), to search one or several annotations within a manifest.
30
+
31
+ #### Request
32
+
33
+ - Variables:
34
+ - `iiif_version` (`2 | 3`): the IIIF aearch API version. 2 is for IIIF Presentation API 3.x, 1 is for IIIF Presentation API 2.x
35
+ - `manifest_short_id` (`string`): the ID of the manifest. See the *IIIF URIs* section.
36
+ - Parameters:
37
+ - `q` (`string`): query string.
38
+ - if `iiif_version=1`, `q` is searched in the fields: `@id`, `resource.@id` or `resource.chars` fields
39
+ - `motivation` (`painting | non-painting | commenting | describing | tagging | linking`): values for the `motivation` field of an annotation
40
+
41
+ #### Reply
42
+
43
+ Returns a JSON. If `iiif_version` is `1`, an `AnnotationList` is returned. Otherwise, an `AnnotationPage` is returned.
44
+
45
+ #### Notes
46
+
47
+ - if `q` and `motivation` are unused, it will return all annotations for the manifest
48
+ - only exact matches are allowed for `q` and `motivation`
49
+
50
+ ---
51
+
52
+ ### Delete an annotation or a manifest
53
+
54
+ ```
55
+ DELETE /{collection_name}/{iiif_version}/delete
56
+ ```
57
+
58
+ #### Request
59
+
60
+ - Variables
61
+ - `collection_name` (`annotations | manifests`): delete an annotation or a manifest
62
+ - `iiif_version` (`2 | 3`): IIIF presentation version
63
+ - Parameters:
64
+ - if `collection_name = manifests`:
65
+ - `uri`: the full URI of the manifest to delete
66
+ - `manifestShortId`: the manifest's identifier
67
+ - if `collection_name = annotation`:
68
+ - `uri`: the full URI of the annotation to delete
69
+ - `manifestShortId`: a manifest's identifier, to delete all annotations for a manifest
70
+ - `canvasUri`: the full URI to an annotation's target canvas, to delete all annotatons for the canvas
71
+
72
+ #### Reply
73
+
74
+ ```
75
+ { deletedCount: <integer> }
76
+ ```
77
+
78
+ ---
79
+
80
+ ## Manifests routes
81
+
82
+ ### Get an index of all manifests
83
+
84
+ ```
85
+ GET /manifests/{iiif_version}
86
+ ```
87
+
88
+ Returns a Collection of all manifests in your **aiiinotate** instance.
89
+
90
+ #### Request
91
+
92
+ - Variables:
93
+ - `iiif_version` (`2 | 3`): the IIIF Presentation API version
94
+
95
+ #### Reply
96
+
97
+ A IIIF `Collection`, following the IIIF Presentation API 2 or 3, depending of the value of `iiif_version`.
98
+
99
+ ---
100
+
101
+ ### Insert a manifest
102
+
103
+ ```
104
+ POST /manifests/{iiif_version}/create
105
+ ```
106
+
107
+ #### Request
108
+
109
+ - Variable:
110
+ - `iiif_version` (`2 | 3`): the IIIF Presentation API version of your manifest
111
+ - Body (`JSON`): the manifest to index in the database
112
+
113
+ #### Reply
114
+
115
+ ```
116
+ {
117
+ insertedIds: string[],
118
+ preExistingIds: string[],
119
+ rejectedIds: []
120
+ }
121
+ ```
122
+
123
+ - `insertedIds`: the list of IDs of inserted manifests
124
+ - `preExisingIds`: the IDs of manifests that were aldready in the database
125
+ - `rejectedIds`: the IDs of manifests on which an error occurred
126
+
3
127
  ---
4
128
 
5
- ## URL prefixes
129
+ ## Annotation routes
130
+
131
+ ### Get all annotations for a canvas
132
+
133
+ ```
134
+ GET /annotations/{iiif_version}/search
135
+ ```
136
+
137
+ #### Request
138
+
139
+ - Variables:
140
+ - `iiif_version` (`2 | 3`): the IIIF Presentation API of your manifests
141
+ - Parameters:
142
+ - `uri` (`string`): the URI of the target canvas
143
+ - `asAnnotationList` (`true | false`): format of the response
144
+
145
+ #### Reply
146
+
147
+ `Object[] | Object`: if `true`, return an array of annotations. Otherwise, return an `AnnotationList`.
148
+
149
+ ---
150
+
151
+ ### Get a single annotation
152
+
153
+ ```
154
+ GET /data/{iiif_version}/{manifest_short_id}/annotation/{annotation_short_id}
155
+ ```
156
+
157
+ This route allows to query an annotation by its ID by defering its `@id | id` field. This URL follows the IIIF specification
158
+
159
+ #### Request
160
+
161
+ - Variables:
162
+ - `iiif_version` (`2 | 3`): the IIIF version of the annotation
163
+ - `manifest_short_id` (`string`): the identifier of the manifest the annotation is related to
164
+ - `annotation_short_id`: the unique part of the annotation URL
165
+
166
+ #### Reply
167
+
168
+ `Object`: the annotation. Its format follows the IIIF Presentation specification 2 or 3, based on the value of `iiif_version`.
169
+
170
+ ---
171
+
172
+ ### Create/update an annotation
173
+
174
+ ```
175
+ POST /annotations/{iiif_version}/{action}
176
+ ```
177
+
178
+ Create or update a single annotation
179
+
180
+ #### Request
181
+
182
+ - Variables:
183
+ - `iiif_version` (`2 | 3`): the IIIF version of the annotation
184
+ - `action` (`create | update`): the action to perform: create or update an annotation
185
+ - Body (`Object`): a IIIF annotation that follows the IIIF Presentation API 2 or 3 (depending on the value of `iiif_version`)
186
+
187
+ #### Reply
188
+
189
+ ```
190
+ {
191
+ insertedIds: string[],
192
+ preExistingIds: string[],
193
+ rejectedIds: []
194
+ }
195
+ ```
196
+
197
+ #### Notes
198
+
199
+ - A side effect of inserting annotations is inserting the related manifests.
200
+ - When inserting an annotation, the annotation's target manifest is also fetched and inserted in the database
201
+ - Annotations in `aiiinotate` contain 3 nonstandard fields. In IIIF presentation 2.x,
202
+ - `annotation.on[0].manifestUri`: the URI of the manifest on which is an annotation
203
+ - `annotation.on[0].manifestShortId`: the unique identifier of the manifest on which is an annotation
204
+ - `annotation.on[0].canvasIdx`: the position of an annotation's target canvas within the target manifest, as an integer
205
+ - this depends on reconstructing an annotation's target manifest URL and fetching it. If this process fails, the fields above will be `undefined`.
206
+ - the annotation's target's manifest is fetched and inserted in the database, if possible, and stored in `annotation.on[0].manifestShortId`
207
+
208
+
209
+ ---
210
+
211
+ ### Insert several annotations
212
+
213
+ ```
214
+ POST /annotations/{iiif_version}/createMany
215
+ ```
216
+
217
+ Batch insert multiple annotations.
218
+
219
+ #### Request
220
+
221
+ - Parameters:
222
+ - `iiif_version` (`2 | 3`): the IIIF version of the annotation
223
+ - Body: either:
224
+ - a full `AnnotationList | AnnotationPage` embedded in the body (type must match `iiif_version`: AnnotationPage for IIIF 3, AnnotationList for IIIF 2).
225
+ - `AnnotationList[] | AnnotationPage[]` (type must match `iiif_version`): an array of annotation lists or pages
226
+ - `{ uri: string }`: an object containing a reference to an `AnnotationList` or `AnnotationPage`
227
+ - `{ uri: string }[]`: an array of objects containing a reference to an `AnnotationList` or `AnnotationPage`.
228
+
229
+ #### Reply
230
+
231
+ ```
232
+ {
233
+ insertedIds: string[],
234
+ preExistingIds: string[],
235
+ rejectedIds: []
236
+ }
237
+ ```
238
+
239
+ #### Notes
240
+
241
+ - Be wary of maximum body size, especially when sending AnnotationLists in your body. If possible, using `{ uri: string }` is better.
242
+ - All annotations within a single AnnotationList/Page may have different target canvases or manifests.
243
+ - See **Create/update an annotation**.
244
+
245
+ ---
246
+
247
+ ## Appending 1: App logic: URL prefixes
6
248
 
7
249
  URL anatomy is a mix of [SAS endpoints](./specifications/4_sas.md) and IIIF specifications. In turn, we define the following prefixes:
8
250
 
@@ -27,22 +269,23 @@ Where:
27
269
 
28
270
  There is an extra URL prefix: `schemas`. It is only used internally (not accessible to clients or accessible through HTTP) to define the IDs of all JsonSchemas, so we won't talk about it here.
29
271
 
272
+
30
273
  ---
31
274
 
32
- ## IIIF URIs
275
+ ## Appendix 2: IIIF URIs
33
276
 
34
277
  IIIF URIs in the Presentation 2.1 API are:
35
278
 
36
279
  ```
37
280
  Collection {scheme}://{host}/{prefix}/collection/{name}
38
- Manifest {scheme}://{host}/{prefix}/{identifier}/manifest
39
- Sequence {scheme}://{host}/{prefix}/{identifier}/sequence/{name}
40
- Canvas {scheme}://{host}/{prefix}/{identifier}/canvas/{name}
41
- Annotation (incl images) {scheme}://{host}/{prefix}/{identifier}/annotation/{name}
42
- AnnotationList {scheme}://{host}/{prefix}/{identifier}/list/{name}
43
- Range {scheme}://{host}/{prefix}/{identifier}/range/{name}
44
- Layer {scheme}://{host}/{prefix}/{identifier}/layer/{name}
45
- Content {scheme}://{host}/{prefix}/{identifier}/res/{name}.{format}
281
+ Manifest {scheme}://{host}/{prefix}/{manifest_short_id}/manifest
282
+ Sequence {scheme}://{host}/{prefix}/{manifest_short_id}/sequence/{name}
283
+ Canvas {scheme}://{host}/{prefix}/{manifest_short_id}/canvas/{name}
284
+ Annotation (incl images) {scheme}://{host}/{prefix}/{manifest_short_id}/annotation/{name}
285
+ AnnotationList {scheme}://{host}/{prefix}/{manifest_short_id}/list/{name}
286
+ Range {scheme}://{host}/{prefix}/{manifest_short_id}/range/{name}
287
+ Layer {scheme}://{host}/{prefix}/{manifest_short_id}/layer/{name}
288
+ Content {scheme}://{host}/{prefix}/{manifest_short_id}/res/{name}.{format}
46
289
  ```
47
290
 
48
291
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiiinotate",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "a fast IIIF-compliant annotation server",
5
5
  "main": "./cli/index.js",
6
6
  "type": "module",
@@ -0,0 +1,12 @@
1
+ import re
2
+ import sys
3
+ import json
4
+ import shutil
5
+ import pathlib
6
+
7
+ curdir = pathlib.Path(__file__).parent.resolve()
8
+ pkg_file = curdir.parent.joinpath("package.json").resolve()
9
+
10
+ with open(pkg_file, mode="r") as fh:
11
+ data = json.load(fh)
12
+ print(f"\naiiinotate current version: {data['version']}\n")
@@ -27,4 +27,4 @@ for fp in [pkg_file, pkg_lock_file]:
27
27
  with open(fp, mode="w") as fh:
28
28
  json.dump(data, fh, indent=2)
29
29
 
30
- print(f"\nUpdated NPM package to version: {version}.")
30
+ print(f"\nUpdated NPM package to version: {version}.\n")
@@ -58,6 +58,24 @@ class Annotations2 extends CollectionAbstract {
58
58
  ////////////////////////////////////////////////////////////////
59
59
  // utils
60
60
 
61
+ /**
62
+ * expand a pair of `filterKey`, `filterVal` following the schema `routeAnnotationFilter` into a proper filter for the `annotations2` collection.
63
+ * @param {string} filterKey
64
+ * @param {string} filterVal
65
+ * @returns
66
+ */
67
+ #expandRouteAnnotationFilter(filterKey, filterVal) {
68
+ const allowedFilterKeys = [ "uri", "manifestShortId", "canvasUri" ];
69
+ if ( !allowedFilterKeys.includes(filterKey) ) {
70
+ throw new Error(`${this.funcname(this.#expandRouteAnnotationFilter)}: expected one of ${allowedFilterKeys} for param 'deleteKey', got '${filterKey}'`)
71
+ }
72
+ return filterKey==="uri"
73
+ ? { "@id": filterVal }
74
+ : filterKey==="canvasUri"
75
+ ? { "on.full": filterVal }
76
+ : { "on.manifestShortId": filterVal };
77
+ }
78
+
61
79
  /**
62
80
  * clean the body of an annotation (annotation.resource).
63
81
  * if `annotation.resource` is an array (there are several bodies associated to that annotation), this function must be called on each item of the array
@@ -275,19 +293,12 @@ class Annotations2 extends CollectionAbstract {
275
293
  * @returns {Promise<DeleteResponseType>}
276
294
  */
277
295
  async deleteAnnotations(deleteKey, deleteVal) {
278
-
279
- const allowedDeleteKey = [ "uri", "manifestShortId", "canvasUri" ];
280
- if ( !allowedDeleteKey.includes(deleteKey) ) {
281
- throw this.deleteError(`${this.funcName(this.deleteAnnotations)}: expected one of ${allowedDeleteKey} for param 'deleteKey', got '${deleteKey}'`)
296
+ try {
297
+ const deleteFilter = this.#expandRouteAnnotationFilter(deleteKey, deleteVal);
298
+ return this.delete(deleteFilter);
299
+ } catch (err) {
300
+ throw this.deleteError(`${this.funcName(this.deleteAnnotations)}: ${err.message}`)
282
301
  }
283
-
284
- const deleteFilter =
285
- deleteKey==="uri"
286
- ? { "@id": deleteVal }
287
- : deleteKey==="canvasUri"
288
- ? { "on.full": deleteVal }
289
- : { "on.manifestShortId": deleteVal };
290
- return this.delete(deleteFilter);
291
302
  }
292
303
 
293
304
  ////////////////////////////////////////////////////////////////
@@ -340,7 +351,7 @@ class Annotations2 extends CollectionAbstract {
340
351
  *
341
352
  * NOTE:
342
353
  * - only `motivation` and `q` search params are implemented
343
- * - to increase search execution, ONLY EXACT STRING MACHES are
354
+ * - to increase search execution speed, ONLY EXACT STRING MACHES are
344
355
  * implemented for `q` and `motivation` (in the IIIF specs, you can supply
345
356
  * multiple space-separated values and the server should return all partial
346
357
  * matches to any of those strings.)
@@ -410,6 +421,23 @@ class Annotations2 extends CollectionAbstract {
410
421
  return this.collection.findOne({ "@id": annotationUri })
411
422
  }
412
423
 
424
+ /**
425
+ * count number of annotations.
426
+ * @param {string} filterKey
427
+ * @param {string} filterVal
428
+ * @returns
429
+ */
430
+ async count(filterKey, filterVal) {
431
+ try {
432
+ const
433
+ countFilter = this.#expandRouteAnnotationFilter(filterKey, filterVal),
434
+ count = await this.collection.countDocuments(countFilter);
435
+ return { count: count }
436
+ } catch (err) {
437
+ throw this.readError(`${this.funcName(this.count)}: ${err.message}`)
438
+ }
439
+ }
440
+
413
441
  }
414
442
 
415
443
  export default fastifyPlugin((fastify, options, done) => {
@@ -64,9 +64,11 @@ function annotationsRoutes(fastify, options, done) {
64
64
  annotations2 = fastify.annotations2,
65
65
  /** @type {Annotations3InstanceType} */
66
66
  annotations3 = fastify.annotations3,
67
- iiifPresentationVersionSchema = fastify.schemasBase.getSchema("presentation"),
68
67
  routeAnnotation2Or3Schema = fastify.schemasRoutes.getSchema("routeAnnotation2Or3"),
69
68
  routeAnnotationCreateManySchema = fastify.schemasRoutes.getSchema("routeAnnotationCreateMany"),
69
+ routeAnnotationFilterSchema = fastify.schemasRoutes.getSchema("routeAnnotationFilter"),
70
+ routeResponseCountSchema = fastify.schemasRoutes.getSchema("routeResponseCount"),
71
+ iiifPresentationVersionSchema = fastify.schemasBase.getSchema("presentation"),
70
72
  iiifAnnotationListSchema = fastify.schemasPresentation2.getSchema("annotationList"),
71
73
  iiifAnnotation2ArraySchema = fastify.schemasPresentation2.getSchema("annotationArray"),
72
74
  iiifAnnotation2Schema = fastify.schemasPresentation2.getSchema("annotation"),
@@ -122,6 +124,36 @@ function annotationsRoutes(fastify, options, done) {
122
124
  }
123
125
  );
124
126
 
127
+ fastify.get(
128
+ "/annotations/:iiifPresentationVersion/count",
129
+ {
130
+ schema: {
131
+ params: {
132
+ type: "object",
133
+ properties: {
134
+ iiifPresentationVersion: iiifPresentationVersionSchema
135
+ }
136
+ },
137
+ querystring: routeAnnotationFilterSchema,
138
+ response: makeResponseSchema(
139
+ fastify, routeResponseCountSchema
140
+ )
141
+ }
142
+ },
143
+ async (request, reply) => {
144
+ const
145
+ { iiifPresentationVersion } = request.params,
146
+ [ filterKey, filterVal ] = getFirstNonEmptyPair(request.query);
147
+ try {
148
+ return iiifPresentationVersion === 2
149
+ ? await annotations2.count(filterKey, filterVal)
150
+ : annotations3.notImplementedError();
151
+ } catch (err) {
152
+ returnError(request, reply, err);
153
+ }
154
+ }
155
+ )
156
+
125
157
  /** retrieve a single annotation by its "@id"|"id". this route defers an annotation */
126
158
  fastify.get(
127
159
  "/data/:iiifPresentationVersion/:manifestShortId/annotation/:annotationShortId",
@@ -176,5 +176,39 @@ test("test annotation Routes", async (t) => {
176
176
  )
177
177
  })
178
178
 
179
+ await t.test("test route /annotations/:iiifPresentationVersion/count", async (t) => {
180
+ const expectedOnCount = (annotationArray, onKey, expectedOnVal) =>
181
+ annotationArray.filter((anno) => anno.on.some(x => x[onKey] === expectedOnVal)).length
182
+
183
+ await injectTestAnnotations(fastify, t, annotationList);
184
+ const
185
+ annotationArray = await fastify.mongo.db.collection("annotations2").find().toArray(),
186
+ annotationId = getRandomItem(annotationArray)["@id"],
187
+ canvasUri = getRandomItem(annotationArray).on[0].full,
188
+ manifestShortId = getRandomItem(annotationArray).on[0].manifestShortId,
189
+ expectedAnnotationIdCount = 1,
190
+ expectedCanvasUriCount = expectedOnCount(annotationArray, "full", canvasUri),
191
+ expectedManifestShortIdCount = expectedOnCount(annotationArray, "manifestShortId", manifestShortId),
192
+ mapper = [
193
+ ["uri", annotationId, expectedAnnotationIdCount],
194
+ ["canvasUri", canvasUri, expectedCanvasUriCount],
195
+ ["manifestShortId", manifestShortId, expectedManifestShortIdCount]
196
+ ];
197
+
198
+ await Promise.all(
199
+ mapper.map(async ([filterKey, filterVal, expectedCount]) => {
200
+ const
201
+ r = await fastify.inject({
202
+ method: "GET",
203
+ url: `/annotations/2/count?${filterKey}=${filterVal}`
204
+ }),
205
+ body = await r.json();
206
+ t.assert.deepStrictEqual(r.statusCode, 200);
207
+ t.assert.deepStrictEqual(body.count, expectedCount);
208
+ })
209
+ )
210
+
211
+ })
212
+
179
213
  return
180
214
  })
@@ -29,10 +29,10 @@ function commonRoutes(fastify, options, done) {
29
29
  routeDeleteSchema = fastify.schemasRoutes.getSchema("routeDelete"),
30
30
  responsePostSchema = makeResponsePostSchema(fastify),
31
31
  validatorRouteAnnotationDeleteSchema = ajvCompile(fastify.schemasResolver(
32
- fastify.schemasRoutes.getSchema("routeAnnotationDelete")
32
+ fastify.schemasRoutes.getSchema("routeAnnotationFilter")
33
33
  )),
34
34
  validatorRouteManifestDeleteSchema = ajvCompile(fastify.schemasResolver(
35
- fastify.schemasRoutes.getSchema("routeManifestDelete")
35
+ fastify.schemasRoutes.getSchema("routeManifestFilter")
36
36
  ));
37
37
 
38
38
  fastify.get(
@@ -163,8 +163,9 @@ function addSchemas(fastify, options, done) {
163
163
  ]
164
164
  })
165
165
 
166
+ // for annotations: key-value pairs to filter a manifests collection by
166
167
  fastify.addSchema({
167
- $id: makeSchemaUri("routeAnnotationDelete"),
168
+ $id: makeSchemaUri("routeAnnotationFilter"),
168
169
  oneOf: [
169
170
  {
170
171
  type: "object",
@@ -200,8 +201,9 @@ function addSchemas(fastify, options, done) {
200
201
  ]
201
202
  });
202
203
 
204
+ // for manifests: key-value pairs to filter a manifests collection by
203
205
  fastify.addSchema({
204
- $id: makeSchemaUri("routeManifestDelete"),
206
+ $id: makeSchemaUri("routeManifestFilter"),
205
207
  type: "object",
206
208
  oneOf: [
207
209
  {
@@ -224,8 +226,8 @@ function addSchemas(fastify, options, done) {
224
226
  $id: makeSchemaUri("routeDelete"),
225
227
  type: "object",
226
228
  oneOf: [
227
- { $ref: makeSchemaUri("routeAnnotationDelete") },
228
- { $ref: makeSchemaUri("routeManifestDelete") },
229
+ { $ref: makeSchemaUri("routeAnnotationFilter") },
230
+ { $ref: makeSchemaUri("routeManifestFilter") },
229
231
  ]
230
232
  });
231
233
 
@@ -288,6 +290,18 @@ function addSchemas(fastify, options, done) {
288
290
  }
289
291
  });
290
292
 
293
+ fastify.addSchema({
294
+ $id: makeSchemaUri("routeResponseCount"),
295
+ type: "object",
296
+ required: [ "count" ],
297
+ properties: {
298
+ count: {
299
+ type: "integer",
300
+ minimum: 0
301
+ }
302
+ }
303
+ })
304
+
291
305
  ////////////////////////////////////////////////////////
292
306
 
293
307
  fastify.decorate("schemasRoutes", {