@opentermsarchive/engine 11.0.2 → 12.0.1

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,10 +1,13 @@
1
+ import config from 'config';
1
2
  import express from 'express';
2
3
  import helmet from 'helmet';
3
4
 
4
5
  import { getCollection } from '../../archivist/collection/index.js';
6
+ import RepositoryFactory from '../../archivist/recorder/repositories/factory.js';
5
7
  import * as Services from '../../archivist/services/index.js';
6
8
 
7
9
  import docsRouter from './docs.js';
10
+ import feedRouter from './feed.js';
8
11
  import metadataRouter from './metadata.js';
9
12
  import servicesRouter from './services.js';
10
13
  import versionsRouter from './versions.js';
@@ -33,10 +36,22 @@ export default async function apiRouter(basePath) {
33
36
 
34
37
  const services = await Services.load();
35
38
  const collection = await getCollection();
39
+ const versionsStorageConfig = config.get('@opentermsarchive/engine.recorder.versions.storage');
40
+ const versionsRepository = await RepositoryFactory.create(versionsStorageConfig).initialize();
41
+ const feedConfig = config.get('@opentermsarchive/engine.collection-api.feed');
42
+
43
+ if (!collection.metadata?.id) {
44
+ throw new Error('Collection metadata "id" is required to expose feed endpoints, as it is used to build the tag URIs that uniquely identify the feed and its entries. Add an "id" field to the collection metadata file.');
45
+ }
46
+
47
+ if (!collection.metadata?.name) {
48
+ throw new Error('Collection metadata "name" is required to expose feed endpoints, as it is used as the Atom feed title which the Atom 1.0 specification requires to be non-empty. Add a "name" field to the collection metadata file.');
49
+ }
36
50
 
37
51
  router.use(await metadataRouter(collection, services));
38
52
  router.use(servicesRouter(services));
39
- router.use(versionsRouter);
53
+ router.use(versionsRouter(versionsRepository));
54
+ router.use(feedRouter(services, versionsRepository, versionsStorageConfig.type, feedConfig.limit, feedConfig.versionUrlTemplate));
40
55
 
41
56
  return router;
42
57
  }
@@ -130,8 +130,7 @@ export default function servicesRouter(services) {
130
130
  * description: No service matching the provided ID is found.
131
131
  */
132
132
  router.get('/service/:serviceId', (req, res) => {
133
- const matchedServiceID = Object.keys(services).find(key => key.toLowerCase() === req.params.serviceId?.toLowerCase());
134
- const service = services[matchedServiceID];
133
+ const service = Object.hasOwn(services, req.params.serviceId) ? services[req.params.serviceId] : null;
135
134
 
136
135
  if (!service) {
137
136
  res.status(404).send('Service not found');
@@ -56,7 +56,6 @@ describe('Services API', () => {
56
56
  describe('GET /service/:serviceId', () => {
57
57
  let response;
58
58
  const SERVICE_ID = 'Service B!';
59
- const CASE_INSENSITIVE_SERVICE_ID = 'service b!';
60
59
 
61
60
  before(async () => {
62
61
  response = await request(app).get(`${basePath}/v1/service/${encodeURI(SERVICE_ID)}`);
@@ -106,49 +105,13 @@ describe('Services API', () => {
106
105
  });
107
106
  });
108
107
 
109
- context('with a case-insensitive service ID parameter', () => {
108
+ context('when the service ID casing does not match', () => {
110
109
  before(async () => {
111
- response = await request(app).get(`${basePath}/v1/service/${encodeURI(CASE_INSENSITIVE_SERVICE_ID)}`);
110
+ response = await request(app).get(`${basePath}/v1/service/${encodeURI(SERVICE_ID.toLowerCase())}`);
112
111
  });
113
112
 
114
- it('responds with 200 status code', () => {
115
- expect(response.status).to.equal(200);
116
- });
117
-
118
- it('returns a service object with id', () => {
119
- expect(response.body).to.have.property('id');
120
- });
121
-
122
- it('returns the proper service object', () => {
123
- expect(response.body.id).to.equal(SERVICE_ID);
124
- });
125
-
126
- it('returns a service object with name', () => {
127
- expect(response.body).to.have.property('name');
128
- });
129
-
130
- it('returns a service object with an array of terms', () => {
131
- expect(response.body).to.have.property('terms').that.is.an('array');
132
- });
133
-
134
- it('each terms should have a type property', () => {
135
- response.body.terms.forEach(terms => {
136
- expect(terms).to.have.property('type');
137
- });
138
- });
139
-
140
- it('each terms should have an array of source documents', () => {
141
- response.body.terms.forEach(terms => {
142
- expect(terms).to.have.property('sourceDocuments').that.is.an('array');
143
- });
144
- });
145
-
146
- it('each source document should have a location', () => {
147
- response.body.terms.forEach(terms => {
148
- terms.sourceDocuments.forEach(sourceDocument => {
149
- expect(sourceDocument).to.have.property('location');
150
- });
151
- });
113
+ it('responds with 404 status code', () => {
114
+ expect(response.status).to.equal(404);
152
115
  });
153
116
  });
154
117
 
@@ -1,10 +1,10 @@
1
- import config from 'config';
2
1
  import express from 'express';
3
2
 
4
- import RepositoryFactory from '../../archivist/recorder/repositories/factory.js';
5
3
  import { toISODateWithoutMilliseconds } from '../../archivist/utils/date.js';
6
4
 
7
5
  /**
6
+ * @param {object} versionsRepository The versions repository instance
7
+ * @returns {express.Router} The router instance
8
8
  * @private
9
9
  * @swagger
10
10
  * tags:
@@ -27,86 +27,86 @@ import { toISODateWithoutMilliseconds } from '../../archivist/utils/date.js';
27
27
  * type: string
28
28
  * description: The JSON-escaped Markdown content of the version
29
29
  */
30
- const router = express.Router();
30
+ export default function versionsRouter(versionsRepository) {
31
+ const router = express.Router();
31
32
 
32
- const versionsRepository = await RepositoryFactory.create(config.get('@opentermsarchive/engine.recorder.versions.storage')).initialize();
33
+ /**
34
+ * @private
35
+ * @swagger
36
+ * /version/{serviceId}/{termsType}/{date}:
37
+ * get:
38
+ * summary: Get a specific version of some terms at a given date.
39
+ * tags: [Versions]
40
+ * produces:
41
+ * - application/json
42
+ * parameters:
43
+ * - in: path
44
+ * name: serviceId
45
+ * description: The ID of the service whose version will be returned.
46
+ * schema:
47
+ * type: string
48
+ * required: true
49
+ * - in: path
50
+ * name: termsType
51
+ * description: The type of terms whose version will be returned.
52
+ * schema:
53
+ * type: string
54
+ * required: true
55
+ * - in: path
56
+ * name: date
57
+ * description: The date and time for which the version is requested, in ISO 8601 format.
58
+ * schema:
59
+ * type: string
60
+ * format: date-time
61
+ * required: true
62
+ * responses:
63
+ * 200:
64
+ * description: A JSON object containing the version content and metadata.
65
+ * content:
66
+ * application/json:
67
+ * schema:
68
+ * $ref: '#/components/schemas/Version'
69
+ * 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.
79
+ * 416:
80
+ * description: The requested date is in the future.
81
+ * content:
82
+ * application/json:
83
+ * schema:
84
+ * type: object
85
+ * properties:
86
+ * error:
87
+ * type: string
88
+ * description: Error message indicating that the requested date is in the future.
89
+ */
90
+ router.get('/version/:serviceId/:termsType/:date', async (req, res) => {
91
+ const { serviceId, termsType, date } = req.params;
92
+ const requestedDate = new Date(date);
33
93
 
34
- /**
35
- * @private
36
- * @swagger
37
- * /version/{serviceId}/{termsType}/{date}:
38
- * get:
39
- * summary: Get a specific version of some terms at a given date.
40
- * tags: [Versions]
41
- * produces:
42
- * - application/json
43
- * parameters:
44
- * - in: path
45
- * name: serviceId
46
- * description: The ID of the service whose version will be returned.
47
- * schema:
48
- * type: string
49
- * required: true
50
- * - in: path
51
- * name: termsType
52
- * description: The type of terms whose version will be returned.
53
- * schema:
54
- * type: string
55
- * required: true
56
- * - in: path
57
- * name: date
58
- * description: The date and time for which the version is requested, in ISO 8601 format.
59
- * schema:
60
- * type: string
61
- * format: date-time
62
- * required: true
63
- * responses:
64
- * 200:
65
- * description: A JSON object containing the version content and metadata.
66
- * content:
67
- * application/json:
68
- * schema:
69
- * $ref: '#/components/schemas/Version'
70
- * 404:
71
- * description: No version found for the specified combination of service ID, terms type and date.
72
- * content:
73
- * application/json:
74
- * schema:
75
- * type: object
76
- * properties:
77
- * error:
78
- * type: string
79
- * description: Error message indicating that no version is found.
80
- * 416:
81
- * description: The requested date is in the future.
82
- * content:
83
- * application/json:
84
- * schema:
85
- * type: object
86
- * properties:
87
- * error:
88
- * type: string
89
- * description: Error message indicating that the requested date is in the future.
90
- */
91
- router.get('/version/:serviceId/:termsType/:date', async (req, res) => {
92
- const { serviceId, termsType, date } = req.params;
93
- const requestedDate = new Date(date);
94
-
95
- if (requestedDate > new Date()) {
96
- return res.status(416).json({ error: 'Requested version is in the future' });
97
- }
94
+ if (requestedDate > new Date()) {
95
+ return res.status(416).json({ error: 'Requested version is in the future' });
96
+ }
98
97
 
99
- const version = await versionsRepository.findByDate(serviceId, termsType, requestedDate);
98
+ const version = await versionsRepository.findByDate(serviceId, termsType, requestedDate);
100
99
 
101
- if (!version) {
102
- return res.status(404).json({ error: `No version found for date ${date}` });
103
- }
100
+ if (!version) {
101
+ return res.status(404).json({ error: `No version found for date ${date}` });
102
+ }
104
103
 
105
- return res.status(200).json({
106
- id: version.id,
107
- fetchDate: toISODateWithoutMilliseconds(version.fetchDate),
108
- content: version.content,
104
+ return res.status(200).json({
105
+ id: version.id,
106
+ fetchDate: toISODateWithoutMilliseconds(version.fetchDate),
107
+ content: version.content,
108
+ });
109
109
  });
110
- });
111
110
 
112
- export default router;
111
+ return router;
112
+ }
@@ -17,7 +17,7 @@ describe('Versions API', () => {
17
17
  let versionsRepository;
18
18
  const FETCH_DATE = new Date('2023-01-01T12:00:00Z');
19
19
  const VERSION_COMMON_ATTRIBUTES = {
20
- serviceId: 'service-1',
20
+ serviceId: 'service·A',
21
21
  termsType: 'Terms of Service',
22
22
  snapshotId: ['snapshot_id'],
23
23
  };
@@ -62,7 +62,7 @@ describe('Versions API', () => {
62
62
 
63
63
  context('when a version is found', () => {
64
64
  before(async () => {
65
- response = await request.get(`${basePath}/v1/version/service-1/Terms%20of%20Service/${encodeURIComponent(toISODateWithoutMilliseconds(FETCH_DATE))}`);
65
+ response = await request.get(`${basePath}/v1/version/service·A/Terms%20of%20Service/${encodeURIComponent(toISODateWithoutMilliseconds(FETCH_DATE))}`);
66
66
  });
67
67
 
68
68
  it('responds with 200 status code', () => {
@@ -80,7 +80,7 @@ describe('Versions API', () => {
80
80
 
81
81
  context('when the requested date is anterior to the first available version', () => {
82
82
  before(async () => {
83
- response = await request.get(`${basePath}/v1/version/service-1/Terms%20of%20Service/2000-01-01T12:00:00Z`);
83
+ response = await request.get(`${basePath}/v1/version/service·A/Terms%20of%20Service/2000-01-01T12:00:00Z`);
84
84
  });
85
85
 
86
86
  it('responds with 404 status code', () => {
@@ -100,7 +100,7 @@ describe('Versions API', () => {
100
100
  before(async () => {
101
101
  const dateInTheFuture = new Date(Date.now() + 60000); // 1 minute in the future
102
102
 
103
- response = await request.get(`${basePath}/v1/version/service-1/Terms%20of%20Service/${encodeURIComponent(toISODateWithoutMilliseconds(dateInTheFuture))}`);
103
+ response = await request.get(`${basePath}/v1/version/service·A/Terms%20of%20Service/${encodeURIComponent(toISODateWithoutMilliseconds(dateInTheFuture))}`);
104
104
  });
105
105
 
106
106
  it('responds with 416 status code', () => {
@@ -8,6 +8,8 @@ import apiRouter from './routes/index.js';
8
8
 
9
9
  const app = express();
10
10
 
11
+ app.set('trust proxy', 'loopback'); // The API binds to 127.0.0.1 and is expected to run behind a reverse proxy. Honour X-Forwarded-* headers only when they come from a local proxy so absolute URLs emitted by routes (notably Atom feed links) reflect the URL seen by clients rather than the internal http://127.0.0.1 hop.
12
+
11
13
  if (process.env.NODE_ENV !== 'test') {
12
14
  app.use(loggerMiddleware);
13
15
  }
@@ -358,7 +358,7 @@ export default class GitLab {
358
358
  try {
359
359
  let apiUrl = `${this.apiBaseURL}/projects/${this.projectId}/issues?search=${encodeURIComponent(title)}&state=${searchParams.state}&per_page=100`;
360
360
 
361
- if (searchParams.state == 'all') apiUrl = `${this.apiBaseURL}/projects/${this.projectId}/issues?search=${encodeURIComponent(title)}&per_page=100`;
361
+ if (searchParams.state == 'all') { apiUrl = `${this.apiBaseURL}/projects/${this.projectId}/issues?search=${encodeURIComponent(title)}&per_page=100`; }
362
362
 
363
363
  const options = GitLab.baseOptionsHttpReq();
364
364
 
@@ -137,7 +137,11 @@ No changes were found in the last run, so no new version has been recorded.`,
137
137
  });
138
138
  const contributionToolUrl = `${CONTRIBUTION_TOOL_URL}?${contributionToolParams}`;
139
139
 
140
- const latestDeclarationLink = `[Latest declaration](${this.reporter.generateDeclarationURL(terms.service.name)})`;
140
+ const declarationFileUrl = this.reporter.generateDeclarationURL(terms.service.name);
141
+ const updateDeclarationLink = terms.hasMultipleSourceDocuments ? `[on GitHub](${declarationFileUrl})` : `[on the contribution tool](${contributionToolUrl})`;
142
+ const multiDocumentsUpdateInfo = terms.hasMultipleSourceDocuments ? ' (the contribution tool does not support multi-document)' : '';
143
+
144
+ const latestDeclarationLink = `[Latest declaration](${declarationFileUrl})`;
141
145
  const latestVersionLink = `[Latest version](${this.reporter.generateVersionURL(terms.service.name, terms.type)})`;
142
146
  const snapshotsBaseUrl = this.reporter.generateSnapshotsBaseUrl(terms.service.name, terms.type);
143
147
  const latestSnapshotsLink = terms.hasMultipleSourceDocuments
@@ -163,13 +167,13 @@ First of all, check if the source documents are accessible through a web browser
163
167
 
164
168
  #### If the source documents are accessible through a web browser
165
169
 
166
- [Edit the declaration](${contributionToolUrl}):
170
+ Edit the declaration ${updateDeclarationLink}${multiDocumentsUpdateInfo}:
167
171
  - Try updating the selectors.
168
172
  - Try switching client scripts on with expert mode.
169
173
 
170
174
  #### If the source documents are not accessible anymore
171
175
 
172
- - If the source documents have moved, find their new location and [update it](${contributionToolUrl}).
176
+ - If the source documents have moved, find their new location and update it ${updateDeclarationLink}.
173
177
  - If these terms have been removed, move them from the declaration to its [history file](${DOC_URL}/terms/explanation/declarations-maintenance/#service-history-reference), using \`${validUntil}\` as the \`validUntil\` value.
174
178
  - If the service has closed, move the entire contents of the declaration to its [history file](${DOC_URL}/contributing-terms/#service-history), using \`${validUntil}\` as the \`validUntil\` value.
175
179
 
@@ -60,4 +60,66 @@ describe('Reporter', () => {
60
60
  });
61
61
  });
62
62
  });
63
+
64
+ describe('#generateDescription', () => {
65
+ const buildReporter = () => new Reporter({
66
+ type: 'github',
67
+ repositories: { declarations: 'OpenTermsArchive/test-declarations' },
68
+ });
69
+
70
+ const buildTerms = ({ sourceCount = 1 } = {}) => {
71
+ const sourceDocuments = Array.from({ length: sourceCount }, (_, index) => ({
72
+ id: `source-${index}`,
73
+ location: `https://example.com/source-${index}`,
74
+ mimeType: 'text/html',
75
+ snapshotId: `snapshot-${index}`,
76
+ toPersistence: () => ({ fetch: `https://example.com/source-${index}` }),
77
+ }));
78
+
79
+ return {
80
+ service: { id: 'TestService', name: 'TestService' },
81
+ type: 'Terms of Service',
82
+ sourceDocuments,
83
+ hasMultipleSourceDocuments: sourceCount > 1,
84
+ toPersistence: () => ({
85
+ name: 'TestService',
86
+ terms: {
87
+ 'Terms of Service': sourceCount > 1
88
+ ? { combine: sourceDocuments.map(sourceDocument => sourceDocument.toPersistence()) }
89
+ : sourceDocuments[0].toPersistence(),
90
+ },
91
+ }),
92
+ };
93
+ };
94
+
95
+ const error = { reasons: ['HTTP code 404'] };
96
+
97
+ context('when the terms has a single source document', () => {
98
+ it('deep-links to the contribution tool with the serialized declaration as the edit target', () => {
99
+ const description = buildReporter().generateDescription({ error, terms: buildTerms({ sourceCount: 1 }) });
100
+
101
+ expect(description).to.match(/\(https:\/\/contribute\.opentermsarchive\.org\/[^)]*\bjson=[^)]*\)/);
102
+ });
103
+ });
104
+
105
+ context('when the terms has multiple source documents (combine)', () => {
106
+ it('does not deep-link to the contribution tool because it cannot edit multi-source declarations', () => {
107
+ const description = buildReporter().generateDescription({ error, terms: buildTerms({ sourceCount: 5 }) });
108
+
109
+ expect(description).to.not.include('json=');
110
+ });
111
+
112
+ it('links to the declaration file on GitHub as the edit target', () => {
113
+ const description = buildReporter().generateDescription({ error, terms: buildTerms({ sourceCount: 5 }) });
114
+
115
+ expect(description).to.include('github.com/OpenTermsArchive/test-declarations/blob/main/declarations/TestService.json');
116
+ });
117
+
118
+ it('keeps the description below the GitHub 65,536-character issue body limit even with many sources', () => {
119
+ const description = buildReporter().generateDescription({ error, terms: buildTerms({ sourceCount: 50 }) });
120
+
121
+ expect(description.length).to.be.lessThan(65000);
122
+ });
123
+ });
124
+ });
63
125
  });