aiiinotate 0.2.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 (88) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +61 -0
  3. package/cli/import.js +142 -0
  4. package/cli/index.js +26 -0
  5. package/cli/io.js +105 -0
  6. package/cli/migrate.js +123 -0
  7. package/cli/mongoClient.js +11 -0
  8. package/docs/architecture.md +88 -0
  9. package/docs/db.md +38 -0
  10. package/docs/dev_iiif_compatibility.md +43 -0
  11. package/docs/endpoints.md +48 -0
  12. package/docs/progress.md +159 -0
  13. package/docs/specifications/0_w3c_open_annotations.md +332 -0
  14. package/docs/specifications/1_w3c_web_annotations.md +577 -0
  15. package/docs/specifications/2_iiif_apis.md +396 -0
  16. package/docs/specifications/3_iiif_annotations.md +103 -0
  17. package/docs/specifications/4_search_api.md +135 -0
  18. package/docs/specifications/5_sas.md +119 -0
  19. package/docs/specifications/6_mirador.md +119 -0
  20. package/docs/specifications/7_aikon.md +137 -0
  21. package/docs/specifications/include/presentation_2.0.webp +0 -0
  22. package/docs/specifications/include/presentation_2.0_white.png +0 -0
  23. package/docs/specifications/include/presentation_3.0.png +0 -0
  24. package/docs/specifications/include/presentation_3.0_resize.png +0 -0
  25. package/eslint.config.js +27 -0
  26. package/migrations/baseConfig.js +56 -0
  27. package/migrations/manageIndex.js +55 -0
  28. package/migrations/migrate-mongo-config-main.js +8 -0
  29. package/migrations/migrate-mongo-config-test.js +8 -0
  30. package/migrations/migrationScripts/20250825185706-collections.js +41 -0
  31. package/migrations/migrationScripts/20250826194832-annotations2-canvas-index.js +31 -0
  32. package/migrations/migrationScripts/20250904080710-annotations2-schema.js +42 -0
  33. package/migrations/migrationScripts/20251002141951-manifest2-schema.js +43 -0
  34. package/migrations/migrationScripts/20251006212110-manifest-unique-index.js +29 -0
  35. package/migrations/migrationScripts/20251028115614-annotations2-id-index.js +27 -0
  36. package/migrations/migrationTemplate.js +25 -0
  37. package/package.json +78 -0
  38. package/run.sh +70 -0
  39. package/scripts/_migrations.sh +79 -0
  40. package/scripts/_setup.js +31 -0
  41. package/scripts/setup_mongodb.sh +61 -0
  42. package/scripts/setup_mongodb_migrate.sh +17 -0
  43. package/scripts/setup_node.sh +15 -0
  44. package/scripts/utils.sh +192 -0
  45. package/setup.sh +20 -0
  46. package/src/app.js +113 -0
  47. package/src/config/.env.template +22 -0
  48. package/src/data/annotations/annotations2.js +419 -0
  49. package/src/data/annotations/annotations3.js +32 -0
  50. package/src/data/annotations/routes.js +271 -0
  51. package/src/data/annotations/routes.test.js +180 -0
  52. package/src/data/collectionAbstract.js +270 -0
  53. package/src/data/index.js +29 -0
  54. package/src/data/manifests/manifests2.js +305 -0
  55. package/src/data/manifests/manifests2.test.js +53 -0
  56. package/src/data/manifests/manifests3.js +23 -0
  57. package/src/data/manifests/routes.js +95 -0
  58. package/src/data/manifests/routes.test.js +69 -0
  59. package/src/data/routes.js +141 -0
  60. package/src/data/routes.test.js +117 -0
  61. package/src/data/utils/iiif2Utils.js +196 -0
  62. package/src/data/utils/iiif2Utils.test.js +98 -0
  63. package/src/data/utils/iiif3Utils.js +0 -0
  64. package/src/data/utils/iiifUtils.js +18 -0
  65. package/src/data/utils/routeUtils.js +109 -0
  66. package/src/data/utils/testUtils.js +253 -0
  67. package/src/data/utils/utils.js +231 -0
  68. package/src/db/index.js +48 -0
  69. package/src/fileServer/annotations.js +39 -0
  70. package/src/fileServer/data/annotationList_aikon_wit9_man11_anno165_all.jsonld +827 -0
  71. package/src/fileServer/data/annotationList_vhs_wit250_man250_anno250_all.jsonld +37514 -0
  72. package/src/fileServer/data/annotationList_vhs_wit253_man253_anno253_all.jsonld +20111 -0
  73. package/src/fileServer/data/annotations2Invalid.jsonld +39 -0
  74. package/src/fileServer/data/annotations2Valid.jsonld +39 -0
  75. package/src/fileServer/data/bnf_invalid_manifest.json +2806 -0
  76. package/src/fileServer/data/bnf_valid_manifest.json +2817 -0
  77. package/src/fileServer/data/vhs_wit253_man253_anno253_anno-24.json +1 -0
  78. package/src/fileServer/index.js +64 -0
  79. package/src/fileServer/manifests.js +14 -0
  80. package/src/fileServer/utils.js +35 -0
  81. package/src/schemas/index.js +20 -0
  82. package/src/schemas/schemasBase.js +47 -0
  83. package/src/schemas/schemasPresentation2.js +417 -0
  84. package/src/schemas/schemasPresentation3.js +57 -0
  85. package/src/schemas/schemasResolver.js +71 -0
  86. package/src/schemas/schemasRoutes.js +277 -0
  87. package/src/server.js +22 -0
  88. package/src/types.js +93 -0
@@ -0,0 +1,271 @@
1
+ import fastifyPlugin from "fastify-plugin"
2
+
3
+ import { pathToUrl, objectHasKey, maybeToArray, inspectObj, throwIfKeyUndefined, throwIfValueError, getFirstNonEmptyPair, visibleLog } from "#utils/utils.js";
4
+ import { makeResponseSchema, makeResponsePostSchema, returnError } from "#utils/routeUtils.js";
5
+
6
+
7
+ /** @typedef {import("#types").FastifyInstanceType} FastifyInstanceType */
8
+ /** @typedef {import("#types").Annotations2InstanceType} Annotations2InstanceType */
9
+ /** @typedef {import("#types").Annotations3InstanceType} Annotations3InstanceType */
10
+
11
+ /**
12
+ * validate an annotation, annotationPage or annotationList: that is, ensure it fits the IIIF presentation API
13
+ * @param {2|3} iiifPresentationVersion
14
+ * @param {object} annotationData
15
+ * @param {boolean} isListOrPage: it's an annotationList or annotationPage instead of a regular annotation.
16
+ */
17
+ const validateAnnotationVersion = (iiifPresentationVersion, annotationData, isListOrPage=false) => {
18
+ // object keys are always strings, so we need to convert to string (https://stackoverflow.com/questions/3633362)
19
+ iiifPresentationVersion = iiifPresentationVersion.toString();
20
+ const expectedTypeKeys = {
21
+ "2": "@type",
22
+ "3": "type"
23
+ };
24
+ throwIfKeyUndefined(expectedTypeKeys, iiifPresentationVersion);
25
+ const expectedTypeKey = expectedTypeKeys[iiifPresentationVersion];
26
+ throwIfKeyUndefined(annotationData, expectedTypeKey);
27
+ const expectedTypeVal = (
28
+ isListOrPage
29
+ ? { "2": "sc:AnnotationList", "3":"AnnotationPage"}
30
+ : { "2": "oa:Annotation", "3": "Annotation" }
31
+ )[iiifPresentationVersion];
32
+ throwIfValueError(annotationData, expectedTypeKey, expectedTypeVal);
33
+ }
34
+
35
+ /**
36
+ * `annotationArray` is an array of AnnotationLists or AnnotationPages, depending on the IIIF Presentaion API version.
37
+ * assert that it indeed the case, raise otherwise
38
+ * @param {2|3} iiifPresentationVersion
39
+ * @param {object[]} annotationArray
40
+ * @returns {void}
41
+ */
42
+ const validateAnnotationArrayVersion = (iiifPresentationVersion, annotationArray) =>
43
+ annotationArray.map(annotationData => validateAnnotationVersion(iiifPresentationVersion, annotationData, true));
44
+
45
+ /**
46
+ * @param {import("#types").InsertResponseArrayType} insertResponseArray
47
+ * @returns {import("#types").InsertResponseType}
48
+ */
49
+ const reduceInsertResponseArray = (insertResponseArray) => ({
50
+ insertedCount: insertResponseArray.reduce((acc, r) => acc+r.insertedCount, 0),
51
+ insertedIds: insertResponseArray.reduce((acc, r) => acc.concat(r.insertedIds), [])
52
+ })
53
+
54
+
55
+ /**
56
+ * Encapsulates the routes
57
+ * @param {FastifyInstanceType} fastify Encapsulated Fastify Instance
58
+ * @param {object} options plugin options, refer to https://fastify.dev/docs/latest/Reference/Plugins/#plugin-options
59
+ * @param {Function} done
60
+ */
61
+ function annotationsRoutes(fastify, options, done) {
62
+ const
63
+ /** @type {Annotations2InstanceType} */
64
+ annotations2 = fastify.annotations2,
65
+ /** @type {Annotations3InstanceType} */
66
+ annotations3 = fastify.annotations3,
67
+ iiifPresentationVersionSchema = fastify.schemasBase.getSchema("presentation"),
68
+ routeAnnotationCreateManySchema = fastify.schemasRoutes.getSchema("routeAnnotationCreateMany"),
69
+ iiifAnnotationListSchema = fastify.schemasPresentation2.getSchema("annotationList"),
70
+ iiifAnnotation2ArraySchema = fastify.schemasPresentation2.getSchema("annotationArray"),
71
+ iiifAnnotation2Schema = fastify.schemasPresentation2.getSchema("annotation"),
72
+ responsePostSchema = makeResponsePostSchema(fastify);
73
+
74
+ /////////////////////////////////////////////////////////
75
+ // get routes
76
+
77
+ /** get all annotations by a canvas URI */
78
+ fastify.get(
79
+ "/annotations/:iiifPresentationVersion/search",
80
+ {
81
+ schema: {
82
+ params: {
83
+ type: "object",
84
+ properties: {
85
+ iiifPresentationVersion: iiifPresentationVersionSchema
86
+ }
87
+ },
88
+ querystring: {
89
+ type: "object",
90
+ properties: {
91
+ uri: { type: "string" },
92
+ asAnnotationList: { type: "boolean" },
93
+ }
94
+ },
95
+ response: makeResponseSchema(
96
+ fastify,
97
+ {
98
+ oneOf: [
99
+ fastify.schemasResolver(iiifAnnotationListSchema),
100
+ fastify.schemasResolver(iiifAnnotation2ArraySchema)
101
+ ]
102
+ },
103
+ )
104
+ },
105
+ },
106
+ async (request, reply) => {
107
+ const
108
+ queryUrl = pathToUrl(request.url),
109
+ { iiifPresentationVersion } = request.params,
110
+ { uri, asAnnotationList } = request.query;
111
+
112
+ try {
113
+ if (iiifPresentationVersion === 2) {
114
+ return await annotations2.findByCanvasUri(queryUrl, uri, asAnnotationList);
115
+ } else {
116
+ annotations3.notImplementedError();
117
+ }
118
+ } catch (err) {
119
+ returnError(request, reply, err);
120
+ }
121
+ }
122
+ );
123
+
124
+ /** retrieve a single annotation by its "@id"|"id". this route defers an annotation */
125
+ fastify.get(
126
+ "/data/:iiifPresentationVersion/:manifestShortId/annotation/:annotationShortId",
127
+ {
128
+ schema: {
129
+ params: {
130
+ type: "object",
131
+ properties: {
132
+ iiifPresentationVersion: iiifPresentationVersionSchema,
133
+ manifestShortId: { type: "string" },
134
+ annotationShortId: { type: "string" },
135
+ }
136
+ },
137
+ response: makeResponseSchema(
138
+ fastify,
139
+ fastify.schemasResolver(iiifAnnotation2Schema)
140
+ )
141
+ }
142
+ },
143
+ async (request, reply) => {
144
+ const
145
+ annotationUri = pathToUrl(request.url),
146
+ { iiifPresentationVersion} = request.params;
147
+ try {
148
+ return iiifPresentationVersion === 2
149
+ ? annotations2.findById(annotationUri)
150
+ : annotations3.notImplementedError();
151
+ } catch (err) {
152
+ returnError(request, reply, err);
153
+ }
154
+ }
155
+ )
156
+
157
+ /////////////////////////////////////////////////////////
158
+ // create/update routes
159
+
160
+ /** create or update a single annotation from an annotation object */
161
+ fastify.post(
162
+ "/annotations/:iiifPresentationVersion/:action",
163
+ {
164
+ schema: {
165
+ params: {
166
+ type: "object",
167
+ properties: {
168
+ iiifPresentationVersion: iiifPresentationVersionSchema,
169
+ action: { type: "string", enum: [ "create", "update" ] }
170
+ }
171
+ },
172
+ body: { type: "object" } /* routeAnnotations2Or3Schema */,
173
+ response: responsePostSchema
174
+ },
175
+ },
176
+ async (request, reply) => {
177
+ const
178
+ { iiifPresentationVersion, action } = request.params,
179
+ annotation = request.body;
180
+
181
+ try {
182
+ validateAnnotationVersion(iiifPresentationVersion, annotation);
183
+ // insert or update
184
+ if ( iiifPresentationVersion === 2 ) {
185
+ return action==="create"
186
+ ? await annotations2.insertAnnotation(annotation)
187
+ : await annotations2.updateAnnotation(annotation);
188
+ } else {
189
+ annotations3.notImplementedError();
190
+ }
191
+
192
+ } catch (err) {
193
+ returnError(request, reply, err, request.body);
194
+ }
195
+ }
196
+ )
197
+
198
+ /**
199
+ * create several annotations from:
200
+ * - an annotationList (if iiifPresentationVersion === 2)
201
+ * - an annotationPage (if iiifPresentationVersion === 3)
202
+ * - an URI to an annotationList or annotationPage
203
+ * - or an Array of any of the previous
204
+ *
205
+ * NOTE that POST body size is limited to 1MB, so your query might be rejected. body size is limited by:
206
+ * - fastify's `bodyLimit` (https://fastify.dev/docs/latest/Reference/Server/#bodylimit)
207
+ * - nginx's `client_max_body_size` (https://nginx.org/en/docs/http/ngx_http_core_module.html#client_max_body_size)
208
+ * - any other server's max body size.
209
+ * both fastify and nginx limit body size defaults to to 1MB.
210
+ */
211
+ fastify.post(
212
+ "/annotations/:iiifPresentationVersion/createMany",
213
+ {
214
+ schema: {
215
+ params: {
216
+ type: "object",
217
+ properties: {
218
+ iiifPresentationVersion: iiifPresentationVersionSchema
219
+ }
220
+ },
221
+ body: routeAnnotationCreateManySchema,
222
+ response: responsePostSchema
223
+ }
224
+ },
225
+ async (request, reply) => {
226
+ const
227
+ { iiifPresentationVersion } = request.params,
228
+ body = maybeToArray(request.body), // convert to an array to have a homogeneous data structure
229
+ insertResponseArray = [];
230
+
231
+ // data to actually insert (body with resolved URIs, if the body is `annotationListOrPageUri` or `annotationListOrPageUriArray`)
232
+ let annotationsArray = [];
233
+
234
+ try {
235
+ // if we received `annotationListOrPageUri` or `annotationListOrPageUriArray`, fetch objects
236
+ const asUri = body.find(item => objectHasKey(item, "uri")) !== undefined;
237
+ if (asUri) {
238
+ annotationsArray = await Promise.all(
239
+ body.map(async (item) =>
240
+ fetch(item.uri).then(r => r.json()))
241
+ );
242
+ } else {
243
+ annotationsArray = body;
244
+ }
245
+
246
+ validateAnnotationArrayVersion(iiifPresentationVersion, annotationsArray);
247
+
248
+ // insert
249
+ if ( iiifPresentationVersion === 2 ) {
250
+ await Promise.all(annotationsArray.map(
251
+ async (annotationList) => {
252
+ const r = await annotations2.insertAnnotationList(annotationList);
253
+ insertResponseArray.push(r);
254
+ }
255
+ ));
256
+ return reduceInsertResponseArray(insertResponseArray);
257
+ } else {
258
+ annotations3.notImplementedError();
259
+ }
260
+
261
+ } catch (err) {
262
+ returnError(request, reply, err, request.body);
263
+ }
264
+ }
265
+ )
266
+
267
+ done();
268
+
269
+ }
270
+
271
+ export default fastifyPlugin(annotationsRoutes);
@@ -0,0 +1,180 @@
1
+ import test from "node:test";
2
+
3
+ import build from "#src/app.js";
4
+
5
+ import { v4 as uuid4 } from "uuid";
6
+
7
+ import { inspectObj, isObject, getRandomItem, visibleLog } from "#utils/utils.js"
8
+ import { testPostRouteCurry, testDeleteRouteCurry, injectTestAnnotations } from "#utils/testUtils.js";
9
+
10
+ /** @typedef {import("#types").NodeTestType} NodeTestType */
11
+ /** @typedef {import("#types").FastifyInstanceType} FastifyInstanceType */
12
+ /** @typedef {import("#types").FastifyReplyType} FastifyReplyType */
13
+ /** @typedef {import("#types").DataOperationsType} DataOperationsType */
14
+
15
+ test("test annotation Routes", async (t) => {
16
+
17
+ const
18
+ fastify = await build("test"),
19
+ testPostRoute = testPostRouteCurry(fastify),
20
+ testPostRouteCreate = testPostRoute("insert"),
21
+ testPostRouteUpdate = testPostRoute("update"),
22
+ testDeleteRoute = testDeleteRouteCurry(fastify),
23
+ testPostRouteCreateSuccess = testPostRouteCreate(true),
24
+ testPostRouteCreateFailure = testPostRouteCreate(false),
25
+ testPostRouteUpdateSuccess = testPostRouteUpdate(true),
26
+ testPostRouteUpdateFailure = testPostRouteUpdate(false),
27
+ {
28
+ annotationListUri,
29
+ annotationListUriArray,
30
+ annotationList,
31
+ annotationListArray,
32
+ annotationListUriInvalid,
33
+ annotationListUriArrayInvalid
34
+ } = fastify.fileServer;
35
+
36
+ await fastify.ready();
37
+ // close the app after running the tests
38
+ t.after(async () => await fastify.close());
39
+ // after each subtest has run, delete all database records
40
+ t.afterEach(async() => fastify.emptyCollections());
41
+
42
+ // NOTE: it is necessary to run the app because internally there are fetches to external data.
43
+ try {
44
+ await fastify.listen({ port: process.env.APP_PORT });
45
+ } catch (err) {
46
+ console.log("FASTIFY ERROR", err);
47
+ throw err;
48
+ }
49
+
50
+ await t.test("test route /annotations/:iiifPresentationVersion/createMany", async (t) => {
51
+ // truncate the contents of `annotationListArray` to avoid an `fst_err_ctp_body_too_large` error
52
+ // `https://fastify.dev/docs/latest/Reference/Errors/#fst_err_ctp_body_too_large`
53
+ const annotationListArrayLimit = annotationListArray.map(a => {
54
+ a.resources = a.resources.length > 500 ? a.resources.slice(0,500) : a.resources
55
+ return a;
56
+ });
57
+
58
+ //NOTE: we can't do Promise.all because it causes a data race that can cause a failure of unique constraints (i.e., on manifests '@id')
59
+ const data = [
60
+ [[ annotationListUri, annotationListUriArray, annotationList, annotationListArrayLimit ], testPostRouteCreateSuccess],
61
+ [[ annotationListUriInvalid, annotationListUriArrayInvalid ], testPostRouteCreateFailure]
62
+ ];
63
+ for ( let i=0; i<data.length; i++ ) {
64
+ let [ testData, func ] = data.at(i);
65
+ for ( let i=0; i<testData.length; i++ ) {
66
+ await func(t, "/annotations/2/createMany", testData.at(i));
67
+ }
68
+ }
69
+ })
70
+
71
+ await t.test("test route /annotations/:iiifPresentationVersion/create", async (t) => {
72
+ //NOTE: we can't do Promise.all because it causes a data race that can cause a failure of unique constraints (i.e., on manifests '@id')
73
+ const data = [
74
+ [fastify.fileServer.annotations2Valid, testPostRouteCreateSuccess],
75
+ [fastify.fileServer.annotations2Invalid, testPostRouteCreateFailure],
76
+ ]
77
+ for ( let i=0; i<data.length; i++ ) {
78
+ let [ testData, func ] = data.at(i);
79
+ for ( let i=0; i<testData.length; i++ ) {
80
+ await func(t, "/annotations/2/create", testData.at(i));
81
+ }
82
+ };
83
+ })
84
+
85
+ await t.test("test route /annotations/:iiifPresentationVersion/update", async (t) => {
86
+ const updatePipeline = async (annotation, success) => {
87
+ // update the annotation
88
+ const
89
+ newLabel = `label-${uuid4()}`,
90
+ newBody = {
91
+ "@type": "cnt:ContentAsText",
92
+ format: "text/html",
93
+ value: "<p>What a grand pleasure it is to have updated this annotation !</p>"
94
+ };
95
+ annotation.label = newLabel;
96
+ annotation.resource = newBody;
97
+ if (!success) {
98
+ annotation["@type"] = "invalidType";
99
+ }
100
+ success
101
+ ? await testPostRouteUpdateSuccess(t, "/annotations/2/update", annotation)
102
+ : await testPostRouteUpdateFailure(t, "/annotations/2/update", annotation);
103
+ }
104
+
105
+ // insert valid documents and retrieve an annotation to update.
106
+ const
107
+ [ insertedCount, insertedIds ] = await injectTestAnnotations(fastify, t, annotationList),
108
+ idToUpdate = getRandomItem(insertedIds), // get a random item
109
+ annotation = await fastify.mongo.db.collection("annotations2").findOne(
110
+ { "@id": idToUpdate },
111
+ { projection: { _id: 0 }}
112
+ );
113
+
114
+ await updatePipeline(annotation, true);
115
+ await updatePipeline(annotation, false);
116
+ return;
117
+ });
118
+
119
+ await t.test("test route /annotations/:iiifPresentationVersion/search", async (t) => {
120
+ await injectTestAnnotations(fastify, t, annotationList);
121
+ await Promise.all(
122
+ // `asAnnotationList` is a boolean defining if we should return an array or an annotationList.
123
+ [false, true].map(async (asAnnotationList) => {
124
+
125
+ const
126
+ annotation = await getRandomItem(
127
+ await fastify.mongo.db.collection("annotations2").find().toArray()
128
+ ),
129
+ canvasId = annotation.on.full,
130
+ r = await fastify.inject({
131
+ method: "GET",
132
+ url: `/annotations/2/search?uri=${canvasId}&asAnnotationList=${asAnnotationList}`
133
+ }),
134
+ body = await r.json();
135
+
136
+ t.assert.deepStrictEqual(r.statusCode, 200);
137
+ if ( asAnnotationList ) {
138
+ // we have aldready defined responses for both cases of `asAnnotationList`, so we just need to check that the response is of a proper type
139
+ t.assert.deepStrictEqual(Array.isArray(body), false);
140
+ } else {
141
+ t.assert.deepStrictEqual(Array.isArray(body), true)
142
+ t.assert.deepStrictEqual(body.length > 0, true);
143
+ }
144
+
145
+ })
146
+ )
147
+
148
+ })
149
+
150
+ await t.test("test route /data/:iiifPresentationVersion/:manifestShortId/annotation/:annotationShortId", async (t) => {
151
+ await injectTestAnnotations(fastify, t, annotationList);
152
+ const annotationId = await getRandomItem(
153
+ await fastify.mongo.db.collection("annotations2").find().toArray()
154
+ )["@id"];
155
+ await Promise.all(
156
+ // if shouldExist, search an annotation that exists, otherwise, search an annotation that does not exist. test accordingly.
157
+ [true, false].map(async (shouldExist) => {
158
+ const
159
+ annotationIdQuery =
160
+ shouldExist
161
+ ? annotationId.replace(process.env.APP_BASE_URL, "")
162
+ : annotationId.replace(process.env.APP_BASE_URL, "") + "string_that_does_not_exist_in_the_db",
163
+ r = await fastify.inject({
164
+ method: "GET",
165
+ url: annotationIdQuery
166
+ }),
167
+ body = await r.json();
168
+
169
+ t.assert.deepStrictEqual(r.statusCode, 200);
170
+ if ( shouldExist ) {
171
+ t.assert.deepStrictEqual(body["@id"]===annotationId, true);
172
+ } else {
173
+ t.assert.deepStrictEqual(Object.keys(body).length === 0, true);
174
+ }
175
+ })
176
+ )
177
+ })
178
+
179
+ return
180
+ })