@opentermsarchive/engine 14.1.0 → 15.0.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.
@@ -1,35 +1,545 @@
1
1
  import express from 'express';
2
2
 
3
3
  import { toISODateWithoutMilliseconds } from '../../archivist/utils/date.js';
4
+ import logger from '../logger.js';
4
5
 
5
6
  /**
6
- * @param {object} versionsRepository The versions repository instance
7
- * @returns {express.Router} The router instance
7
+ * @param {object} versionsRepository The versions repository instance
8
+ * @param {object} snapshotsRepository The snapshots repository instance
9
+ * @returns {express.Router} The router instance
8
10
  * @private
9
11
  * @swagger
10
12
  * tags:
11
13
  * name: Versions
12
14
  * description: Versions API
13
15
  * components:
16
+ * parameters:
17
+ * LimitParam:
18
+ * in: query
19
+ * name: limit
20
+ * description: |
21
+ * The maximum number of versions to return.
22
+ *
23
+ * **Note for Git storage**: Pagination uses Git's `--skip` and `--max-count` options,
24
+ * which work in topological order rather than strictly chronological order.
25
+ * This means paginated results may not be in perfect chronological sequence,
26
+ * but this is an acceptable performance trade-off.
27
+ * schema:
28
+ * type: integer
29
+ * minimum: 1
30
+ * maximum: 500
31
+ * default: 100
32
+ * required: false
33
+ * OffsetParam:
34
+ * in: query
35
+ * name: offset
36
+ * description: |
37
+ * The number of versions to skip before returning results.
38
+ *
39
+ * **Note for Git storage**: Pagination uses Git's `--skip` and `--max-count` options,
40
+ * which work in topological order rather than strictly chronological order.
41
+ * schema:
42
+ * type: integer
43
+ * minimum: 0
44
+ * default: 0
45
+ * required: false
14
46
  * schemas:
15
47
  * Version:
16
48
  * type: object
17
49
  * description: Version content and metadata
18
50
  * properties:
51
+ * id:
52
+ * type: string
53
+ * description: The ID of the version.
54
+ * serviceId:
55
+ * type: string
56
+ * description: The ID of the service.
57
+ * termsType:
58
+ * type: string
59
+ * description: The type of terms.
19
60
  * fetchDate:
20
61
  * type: string
21
62
  * format: date-time
22
63
  * description: The ISO 8601 datetime string when the version was recorded.
64
+ * isFirstRecord:
65
+ * type: boolean
66
+ * description: Whether this version is the first one recorded for this service and terms type.
67
+ * isTechnicalUpgrade:
68
+ * type: boolean
69
+ * description: Whether this version is a technical upgrade (a re-render of an existing snapshot) rather than a content change at the source.
70
+ * content:
71
+ * type: string
72
+ * description: The JSON-escaped Markdown content of the version
73
+ * VersionListItem:
74
+ * type: object
75
+ * properties:
23
76
  * id:
24
77
  * type: string
25
78
  * description: The ID of the version.
26
- * content:
79
+ * serviceId:
27
80
  * type: string
28
- * description: The JSON-escaped Markdown content of the version
81
+ * description: The ID of the service.
82
+ * termsType:
83
+ * type: string
84
+ * description: The type of terms.
85
+ * fetchDate:
86
+ * type: string
87
+ * format: date-time
88
+ * description: The ISO 8601 datetime string when the version was recorded.
89
+ * isFirstRecord:
90
+ * type: boolean
91
+ * description: Whether this version is the first one recorded for this service and terms type.
92
+ * isTechnicalUpgrade:
93
+ * type: boolean
94
+ * description: Whether this version is a technical upgrade (a re-render of an existing snapshot) rather than a content change at the source.
95
+ * VersionListItemWithStats:
96
+ * allOf:
97
+ * - $ref: '#/components/schemas/VersionListItem'
98
+ * - type: object
99
+ * properties:
100
+ * additions:
101
+ * type: integer
102
+ * nullable: true
103
+ * description: The number of lines added in this version, or null if not available.
104
+ * deletions:
105
+ * type: integer
106
+ * nullable: true
107
+ * description: The number of lines deleted in this version, or null if not available.
108
+ * PaginatedVersionsResponse:
109
+ * type: object
110
+ * properties:
111
+ * data:
112
+ * type: array
113
+ * description: The list of versions.
114
+ * items:
115
+ * $ref: '#/components/schemas/VersionListItem'
116
+ * count:
117
+ * type: integer
118
+ * description: The total number of versions found.
119
+ * limit:
120
+ * type: integer
121
+ * description: The maximum number of versions returned in this response.
122
+ * offset:
123
+ * type: integer
124
+ * description: The number of versions skipped before returning results.
125
+ * PaginatedVersionsWithStatsResponse:
126
+ * type: object
127
+ * properties:
128
+ * data:
129
+ * type: array
130
+ * description: The list of versions with diff statistics.
131
+ * items:
132
+ * $ref: '#/components/schemas/VersionListItemWithStats'
133
+ * count:
134
+ * type: integer
135
+ * description: The total number of versions found.
136
+ * limit:
137
+ * type: integer
138
+ * description: The maximum number of versions returned in this response.
139
+ * offset:
140
+ * type: integer
141
+ * description: The number of versions skipped before returning results.
142
+ * VersionWithLinks:
143
+ * allOf:
144
+ * - $ref: '#/components/schemas/Version'
145
+ * - type: object
146
+ * properties:
147
+ * additions:
148
+ * type: integer
149
+ * nullable: true
150
+ * description: The number of lines added in this version, or null if not available.
151
+ * deletions:
152
+ * type: integer
153
+ * nullable: true
154
+ * description: The number of lines deleted in this version, or null if not available.
155
+ * fetchUrls:
156
+ * type: array
157
+ * description: The URLs of the source documents that were fetched to produce this version.
158
+ * items:
159
+ * type: string
160
+ * format: uri
161
+ * links:
162
+ * type: object
163
+ * description: Navigation links to related versions.
164
+ * properties:
165
+ * first:
166
+ * type: string
167
+ * description: The ID of the first version for this service and terms type.
168
+ * nullable: true
169
+ * prev:
170
+ * type: string
171
+ * description: The ID of the previous version, or null if this is the first.
172
+ * nullable: true
173
+ * next:
174
+ * type: string
175
+ * description: The ID of the next version, or null if this is the last.
176
+ * nullable: true
177
+ * last:
178
+ * type: string
179
+ * description: The ID of the last version for this service and terms type.
180
+ * nullable: true
181
+ * ErrorResponse:
182
+ * type: object
183
+ * properties:
184
+ * error:
185
+ * type: string
186
+ * description: Error message.
187
+ * responses:
188
+ * BadRequestError:
189
+ * description: Invalid pagination parameters.
190
+ * content:
191
+ * application/json:
192
+ * schema:
193
+ * $ref: '#/components/schemas/ErrorResponse'
194
+ * NotFoundError:
195
+ * description: Resource not found.
196
+ * content:
197
+ * application/json:
198
+ * schema:
199
+ * $ref: '#/components/schemas/ErrorResponse'
29
200
  */
30
- export default function versionsRouter(versionsRepository) {
201
+ export default function versionsRouter(versionsRepository, snapshotsRepository) {
31
202
  const router = express.Router();
32
203
 
204
+ function parsePaginationParams(query) {
205
+ const limit = query.limit ? parseInt(query.limit, 10) : 100;
206
+ const offset = query.offset ? parseInt(query.offset, 10) : 0;
207
+
208
+ return { limit, offset };
209
+ }
210
+
211
+ function validatePaginationParams(limit, offset) {
212
+ if (Number.isNaN(limit) || limit < 1) {
213
+ return { error: 'Invalid limit parameter. Must be a positive integer.' };
214
+ }
215
+
216
+ if (limit > 500) {
217
+ return { error: 'Invalid limit parameter. Must not exceed 500.' };
218
+ }
219
+
220
+ if (Number.isNaN(offset) || offset < 0) {
221
+ return { error: 'Invalid offset parameter. Must be a non-negative integer.' };
222
+ }
223
+
224
+ return null;
225
+ }
226
+
227
+ function mapVersionToListItem(version) {
228
+ return {
229
+ id: version.id,
230
+ serviceId: version.serviceId,
231
+ termsType: version.termsType,
232
+ fetchDate: toISODateWithoutMilliseconds(version.fetchDate),
233
+ isFirstRecord: version.isFirstRecord,
234
+ isTechnicalUpgrade: version.isTechnicalUpgrade,
235
+ };
236
+ }
237
+
238
+ async function getFetchUrls(snapshotIds) {
239
+ if (!snapshotIds?.length) {
240
+ return [];
241
+ }
242
+
243
+ const snapshots = await Promise.all(snapshotIds.map(async id => {
244
+ const snapshot = await snapshotsRepository.findMetadataById(id);
245
+
246
+ if (!snapshot) {
247
+ logger.warn(`Could not resolve source snapshot ${id}; its fetch URL will be missing from the version. The snapshots repository is likely missing or out of sync with the versions repository.`);
248
+ }
249
+
250
+ return snapshot;
251
+ }));
252
+
253
+ return snapshots
254
+ .filter(Boolean)
255
+ .map(snapshot => snapshot.metadata?.['x-source-document-location'])
256
+ .filter(Boolean);
257
+ }
258
+
259
+ // Builds the full detail response shared by every single-version endpoint, so they cannot drift apart
260
+ async function buildVersionDetail(version) {
261
+ const [ navigationIds, stats, fetchUrls ] = await Promise.all([
262
+ versionsRepository.getNavigationIds(version.serviceId, version.termsType, version.id),
263
+ versionsRepository.getDiffStats(version.id),
264
+ getFetchUrls(version.snapshotIds),
265
+ ]);
266
+
267
+ return {
268
+ id: version.id,
269
+ serviceId: version.serviceId,
270
+ termsType: version.termsType,
271
+ fetchDate: toISODateWithoutMilliseconds(version.fetchDate),
272
+ content: version.content,
273
+ isFirstRecord: version.isFirstRecord,
274
+ isTechnicalUpgrade: version.isTechnicalUpgrade,
275
+ fetchUrls,
276
+ links: {
277
+ first: navigationIds.first,
278
+ prev: navigationIds.prev,
279
+ next: navigationIds.next,
280
+ last: navigationIds.last,
281
+ },
282
+ ...stats,
283
+ };
284
+ }
285
+
286
+ /**
287
+ * @private
288
+ * @swagger
289
+ * /versions:
290
+ * get:
291
+ * summary: Get all versions.
292
+ * tags: [Versions]
293
+ * produces:
294
+ * - application/json
295
+ * parameters:
296
+ * - $ref: '#/components/parameters/LimitParam'
297
+ * - $ref: '#/components/parameters/OffsetParam'
298
+ * responses:
299
+ * 200:
300
+ * description: A JSON object containing the list of all versions and metadata.
301
+ * content:
302
+ * application/json:
303
+ * schema:
304
+ * $ref: '#/components/schemas/PaginatedVersionsResponse'
305
+ * 400:
306
+ * $ref: '#/components/responses/BadRequestError'
307
+ */
308
+ router.get('/versions', async (req, res) => {
309
+ const { limit, offset } = parsePaginationParams(req.query);
310
+ const validationError = validatePaginationParams(limit, offset);
311
+
312
+ if (validationError) {
313
+ return res.status(400).json(validationError);
314
+ }
315
+
316
+ const paginatedVersions = await versionsRepository.findAll({ limit, offset });
317
+
318
+ const versionsList = paginatedVersions.map(mapVersionToListItem);
319
+
320
+ const response = {
321
+ data: versionsList,
322
+ count: await versionsRepository.count(),
323
+ limit,
324
+ offset,
325
+ };
326
+
327
+ return res.status(200).json(response);
328
+ });
329
+
330
+ /**
331
+ * @private
332
+ * @swagger
333
+ * /versions/{serviceId}:
334
+ * get:
335
+ * summary: Get all versions for a specific service.
336
+ * tags: [Versions]
337
+ * produces:
338
+ * - application/json
339
+ * parameters:
340
+ * - in: path
341
+ * name: serviceId
342
+ * description: The ID of the service whose versions will be returned.
343
+ * schema:
344
+ * type: string
345
+ * required: true
346
+ * - $ref: '#/components/parameters/LimitParam'
347
+ * - $ref: '#/components/parameters/OffsetParam'
348
+ * responses:
349
+ * 200:
350
+ * description: A JSON object containing the list of versions and metadata.
351
+ * content:
352
+ * application/json:
353
+ * schema:
354
+ * $ref: '#/components/schemas/PaginatedVersionsResponse'
355
+ * 400:
356
+ * $ref: '#/components/responses/BadRequestError'
357
+ * 404:
358
+ * $ref: '#/components/responses/NotFoundError'
359
+ */
360
+ router.get('/versions/:serviceId', async (req, res) => {
361
+ const { serviceId } = req.params;
362
+ const { limit, offset } = parsePaginationParams(req.query);
363
+ const validationError = validatePaginationParams(limit, offset);
364
+
365
+ if (validationError) {
366
+ return res.status(400).json(validationError);
367
+ }
368
+
369
+ const totalCount = await versionsRepository.count(serviceId);
370
+
371
+ if (totalCount === 0) {
372
+ return res.status(404).json({ error: `No versions found for service "${serviceId}"` });
373
+ }
374
+
375
+ const paginatedVersions = await versionsRepository.findByService(serviceId, { limit, offset });
376
+
377
+ const versionsList = paginatedVersions.map(mapVersionToListItem);
378
+
379
+ const response = {
380
+ data: versionsList,
381
+ count: totalCount,
382
+ limit,
383
+ offset,
384
+ };
385
+
386
+ return res.status(200).json(response);
387
+ });
388
+
389
+ /**
390
+ * @private
391
+ * @swagger
392
+ * /versions/{serviceId}/{termsType}:
393
+ * get:
394
+ * summary: Get all versions of some terms for a specific service.
395
+ * tags: [Versions]
396
+ * produces:
397
+ * - application/json
398
+ * parameters:
399
+ * - in: path
400
+ * name: serviceId
401
+ * description: The ID of the service whose versions will be returned.
402
+ * schema:
403
+ * type: string
404
+ * required: true
405
+ * - in: path
406
+ * name: termsType
407
+ * description: The type of terms whose versions will be returned.
408
+ * schema:
409
+ * type: string
410
+ * required: true
411
+ * - $ref: '#/components/parameters/LimitParam'
412
+ * - $ref: '#/components/parameters/OffsetParam'
413
+ * responses:
414
+ * 200:
415
+ * description: A JSON object containing the list of versions with diff statistics and metadata.
416
+ * content:
417
+ * application/json:
418
+ * schema:
419
+ * $ref: '#/components/schemas/PaginatedVersionsWithStatsResponse'
420
+ * 400:
421
+ * $ref: '#/components/responses/BadRequestError'
422
+ * 404:
423
+ * $ref: '#/components/responses/NotFoundError'
424
+ */
425
+ router.get('/versions/:serviceId/:termsType', async (req, res) => {
426
+ const { serviceId, termsType } = req.params;
427
+ const { limit, offset } = parsePaginationParams(req.query);
428
+ const validationError = validatePaginationParams(limit, offset);
429
+
430
+ if (validationError) {
431
+ return res.status(400).json(validationError);
432
+ }
433
+
434
+ const totalCount = await versionsRepository.count(serviceId, termsType);
435
+
436
+ if (totalCount === 0) {
437
+ return res.status(404).json({ error: `No versions found for service "${serviceId}" and terms type "${termsType}"` });
438
+ }
439
+
440
+ const paginatedVersions = await versionsRepository.findByServiceAndTermsType(serviceId, termsType, { limit, offset });
441
+
442
+ const versionsList = await Promise.all(paginatedVersions.map(async version => {
443
+ const stats = await versionsRepository.getDiffStats(version.id);
444
+
445
+ return {
446
+ ...mapVersionToListItem(version),
447
+ ...stats,
448
+ };
449
+ }));
450
+
451
+ const response = {
452
+ data: versionsList,
453
+ count: totalCount,
454
+ limit,
455
+ offset,
456
+ };
457
+
458
+ return res.status(200).json(response);
459
+ });
460
+
461
+ /**
462
+ * @private
463
+ * @swagger
464
+ * /version/{versionId}:
465
+ * get:
466
+ * summary: Get a specific version by its ID.
467
+ * tags: [Versions]
468
+ * produces:
469
+ * - application/json
470
+ * parameters:
471
+ * - in: path
472
+ * name: versionId
473
+ * description: The ID of the version to retrieve.
474
+ * schema:
475
+ * type: string
476
+ * required: true
477
+ * responses:
478
+ * 200:
479
+ * description: A JSON object containing the version content, metadata, and navigation links.
480
+ * content:
481
+ * application/json:
482
+ * schema:
483
+ * $ref: '#/components/schemas/VersionWithLinks'
484
+ * 404:
485
+ * $ref: '#/components/responses/NotFoundError'
486
+ */
487
+ router.get('/version/:versionId', async (req, res) => {
488
+ const { versionId } = req.params;
489
+
490
+ const version = await versionsRepository.findById(versionId);
491
+
492
+ if (!version) {
493
+ return res.status(404).json({ error: `No version found with ID "${versionId}"` });
494
+ }
495
+
496
+ return res.status(200).json(await buildVersionDetail(version));
497
+ });
498
+
499
+ /**
500
+ * @private
501
+ * @swagger
502
+ * /version/{serviceId}/{termsType}/latest:
503
+ * get:
504
+ * summary: Get the latest version of some terms for a service.
505
+ * tags: [Versions]
506
+ * produces:
507
+ * - application/json
508
+ * parameters:
509
+ * - in: path
510
+ * name: serviceId
511
+ * description: The ID of the service whose version will be returned.
512
+ * schema:
513
+ * type: string
514
+ * required: true
515
+ * - in: path
516
+ * name: termsType
517
+ * description: The type of terms whose version will be returned.
518
+ * schema:
519
+ * type: string
520
+ * required: true
521
+ * responses:
522
+ * 200:
523
+ * description: A JSON object containing the version content, metadata, and navigation links.
524
+ * content:
525
+ * application/json:
526
+ * schema:
527
+ * $ref: '#/components/schemas/VersionWithLinks'
528
+ * 404:
529
+ * $ref: '#/components/responses/NotFoundError'
530
+ */
531
+ router.get('/version/:serviceId/:termsType/latest', async (req, res) => {
532
+ const { serviceId, termsType } = req.params;
533
+
534
+ const version = await versionsRepository.findLatest(serviceId, termsType);
535
+
536
+ if (!version) {
537
+ return res.status(404).json({ error: `No version found for service "${serviceId}" and terms type "${termsType}"` });
538
+ }
539
+
540
+ return res.status(200).json(await buildVersionDetail(version));
541
+ });
542
+
33
543
  /**
34
544
  * @private
35
545
  * @swagger
@@ -61,31 +571,19 @@ export default function versionsRouter(versionsRepository) {
61
571
  * required: true
62
572
  * responses:
63
573
  * 200:
64
- * description: A JSON object containing the version content and metadata.
574
+ * description: A JSON object containing the version content, metadata, and navigation links.
65
575
  * content:
66
576
  * application/json:
67
577
  * schema:
68
- * $ref: '#/components/schemas/Version'
578
+ * $ref: '#/components/schemas/VersionWithLinks'
69
579
  * 404:
70
- * description: No version found for the specified combination of service ID, terms type and date.
71
- * content:
72
- * application/json:
73
- * schema:
74
- * type: object
75
- * properties:
76
- * error:
77
- * type: string
78
- * description: Error message indicating that no version is found.
580
+ * $ref: '#/components/responses/NotFoundError'
79
581
  * 416:
80
582
  * description: The requested date is in the future.
81
583
  * content:
82
584
  * application/json:
83
585
  * schema:
84
- * type: object
85
- * properties:
86
- * error:
87
- * type: string
88
- * description: Error message indicating that the requested date is in the future.
586
+ * $ref: '#/components/schemas/ErrorResponse'
89
587
  */
90
588
  router.get('/version/:serviceId/:termsType/:date', async (req, res) => {
91
589
  const { serviceId, termsType, date } = req.params;
@@ -98,14 +596,10 @@ export default function versionsRouter(versionsRepository) {
98
596
  const version = await versionsRepository.findByDate(serviceId, termsType, requestedDate);
99
597
 
100
598
  if (!version) {
101
- return res.status(404).json({ error: `No version found for date ${date}` });
599
+ return res.status(404).json({ error: `No version found for service "${serviceId}" and terms type "${termsType}" at date ${date}` });
102
600
  }
103
601
 
104
- return res.status(200).json({
105
- id: version.id,
106
- fetchDate: toISODateWithoutMilliseconds(version.fetchDate),
107
- content: version.content,
108
- });
602
+ return res.status(200).json(await buildVersionDetail(version));
109
603
  });
110
604
 
111
605
  return router;