@opentermsarchive/engine 3.0.0 → 4.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.
package/config/ci.json CHANGED
@@ -1,7 +1,5 @@
1
1
  {
2
2
  "@opentermsarchive/engine": {
3
- "services": {
4
- "declarationsPath": "./demo-declarations/declarations"
5
- }
3
+ "collectionPath": "./demo-declarations/"
6
4
  }
7
5
  }
@@ -1,9 +1,7 @@
1
1
  {
2
2
  "@opentermsarchive/engine": {
3
3
  "trackingSchedule": "30 */12 * * *",
4
- "services": {
5
- "declarationsPath": "./declarations"
6
- },
4
+ "collectionPath": "./",
7
5
  "recorder": {
8
6
  "versions": {
9
7
  "storage": {
package/config/test.json CHANGED
@@ -1,8 +1,6 @@
1
1
  {
2
2
  "@opentermsarchive/engine": {
3
- "services": {
4
- "declarationsPath": "./test/services"
5
- },
3
+ "collectionPath": "./test/test-declarations",
6
4
  "recorder": {
7
5
  "versions": {
8
6
  "storage": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opentermsarchive/engine",
3
- "version": "3.0.0",
3
+ "version": "4.0.1",
4
4
  "description": "Tracks and makes visible changes to the terms of online services",
5
5
  "homepage": "https://opentermsarchive.org",
6
6
  "bugs": {
@@ -82,6 +82,7 @@
82
82
  "https-proxy-agent": "^5.0.0",
83
83
  "iconv-lite": "^0.6.3",
84
84
  "joplin-turndown-plugin-gfm": "^1.0.12",
85
+ "js-yaml": "^4.1.0",
85
86
  "jsdom": "^18.1.0",
86
87
  "json-source-map": "^0.6.1",
87
88
  "lodash": "^4.17.21",
@@ -16,8 +16,8 @@ const ESLINT_CONFIG_PATH = path.join(ROOT_PATH, '.eslintrc.yaml');
16
16
  const eslint = new ESLint({ overrideConfigFile: ESLINT_CONFIG_PATH, fix: false });
17
17
  const eslintWithFix = new ESLint({ overrideConfigFile: ESLINT_CONFIG_PATH, fix: true });
18
18
 
19
- const declarationsPath = path.resolve(process.cwd(), config.get('@opentermsarchive/engine.services.declarationsPath'));
20
- const instancePath = path.resolve(declarationsPath, '../');
19
+ const instancePath = path.resolve(process.cwd(), config.get('@opentermsarchive/engine.collectionPath'));
20
+ const declarationsPath = path.resolve(instancePath, services.DECLARATIONS_PATH);
21
21
 
22
22
  export default async options => {
23
23
  let servicesToValidate = options.services || [];
@@ -19,8 +19,8 @@ const fs = fsApi.promises;
19
19
  const MIN_DOC_LENGTH = 100;
20
20
  const SLOW_DOCUMENT_THRESHOLD = 10 * 1000; // number of milliseconds after which a document fetch is considered slow
21
21
 
22
- const declarationsPath = path.resolve(process.cwd(), config.get('@opentermsarchive/engine.services.declarationsPath'));
23
- const instancePath = path.resolve(declarationsPath, '../');
22
+ const instancePath = path.resolve(process.cwd(), config.get('@opentermsarchive/engine.collectionPath'));
23
+ const declarationsPath = path.resolve(instancePath, services.DECLARATIONS_PATH);
24
24
 
25
25
  export default async options => {
26
26
  const schemaOnly = options.schemaOnly || false;
@@ -1,4 +1,4 @@
1
- import fsApi from 'fs';
1
+ import fs from 'fs/promises';
2
2
  import path from 'path';
3
3
  import { pathToFileURL } from 'url';
4
4
 
@@ -8,8 +8,8 @@ import Service from './service.js';
8
8
  import SourceDocument from './sourceDocument.js';
9
9
  import Terms from './terms.js';
10
10
 
11
- const fs = fsApi.promises;
12
- const declarationsPath = path.resolve(process.cwd(), config.get('@opentermsarchive/engine.services.declarationsPath'));
11
+ export const DECLARATIONS_PATH = './declarations';
12
+ const declarationsPath = path.resolve(process.cwd(), config.get('@opentermsarchive/engine.collectionPath'), DECLARATIONS_PATH);
13
13
 
14
14
  export async function load(servicesIdsToLoad = []) {
15
15
  let servicesIds = await getDeclaredServicesIds();
@@ -70,6 +70,6 @@ export default class Service {
70
70
  }
71
71
 
72
72
  static getNumberOfTerms(services, servicesIds, termsTypes) {
73
- return servicesIds.reduce((acc, serviceId) => acc + services[serviceId].getNumberOfTerms(termsTypes), 0);
73
+ return (servicesIds || Object.keys(services)).reduce((acc, serviceId) => acc + services[serviceId].getNumberOfTerms(termsTypes), 0);
74
74
  }
75
75
  }
@@ -1,11 +1,17 @@
1
+ import path from 'path';
2
+
3
+ import config from 'config';
1
4
  import express from 'express';
2
5
  import helmet from 'helmet';
3
6
 
7
+ import * as Services from '../../archivist/services/index.js';
8
+
4
9
  import docsRouter from './docs.js';
10
+ import metadataRouter from './metadata.js';
5
11
  import servicesRouter from './services.js';
6
12
  import versionsRouter from './versions.js';
7
13
 
8
- export default function apiRouter(basePath) {
14
+ export default async function apiRouter(basePath) {
9
15
  const router = express.Router();
10
16
 
11
17
  const defaultDirectives = helmet.contentSecurityPolicy.getDefaultDirectives();
@@ -27,7 +33,11 @@ export default function apiRouter(basePath) {
27
33
  res.json({ message: 'Welcome to an instance of the Open Terms Archive API. Documentation is available at /docs. Learn more on Open Terms Archive on https://opentermsarchive.org.' });
28
34
  });
29
35
 
30
- router.use(servicesRouter);
36
+ const collectionPath = path.resolve(process.cwd(), config.get('@opentermsarchive/engine.collectionPath'));
37
+ const services = await Services.load();
38
+
39
+ router.use(await metadataRouter(collectionPath, services));
40
+ router.use(servicesRouter(services));
31
41
  router.use(versionsRouter);
32
42
 
33
43
  return router;
@@ -0,0 +1,151 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+
4
+ import express from 'express';
5
+ import yaml from 'js-yaml';
6
+
7
+ import Service from '../../archivist/services/service.js';
8
+
9
+ const METADATA_FILENAME = 'metadata.yml';
10
+ const PACKAGE_JSON_PATH = '../../../package.json';
11
+
12
+ /**
13
+ * @swagger
14
+ * tags:
15
+ * name: Metadata
16
+ * description: Collection metadata API
17
+ * components:
18
+ * schemas:
19
+ * Metadata:
20
+ * type: object
21
+ * description: Collection metadata
22
+ * properties:
23
+ * id:
24
+ * type: string
25
+ * description: Unique identifier of the collection
26
+ * name:
27
+ * type: string
28
+ * description: Display name of the collection
29
+ * tagline:
30
+ * type: string
31
+ * description: Short description of the collection
32
+ * description:
33
+ * type: string
34
+ * nullable: true
35
+ * description: Detailed description of the collection
36
+ * totalTerms:
37
+ * type: integer
38
+ * description: Total number of terms tracked in the collection
39
+ * totalServices:
40
+ * type: integer
41
+ * description: Total number of services tracked in the collection
42
+ * engineVersion:
43
+ * type: string
44
+ * description: Version of the Open Terms Archive engine in SemVer format (MAJOR.MINOR.PATCH)
45
+ * dataset:
46
+ * type: string
47
+ * format: uri
48
+ * description: URL to the dataset releases
49
+ * declarations:
50
+ * type: string
51
+ * format: uri
52
+ * description: URL to the declarations repository
53
+ * versions:
54
+ * type: string
55
+ * format: uri
56
+ * description: URL to the versions repository
57
+ * snapshots:
58
+ * type: string
59
+ * format: uri
60
+ * description: URL to the snapshots repository
61
+ * donations:
62
+ * type: string
63
+ * format: uri
64
+ * description: URL to the donations page
65
+ * logo:
66
+ * type: string
67
+ * format: uri
68
+ * nullable: true
69
+ * description: URL to the collection logo
70
+ * languages:
71
+ * type: array
72
+ * items:
73
+ * type: string
74
+ * description: List of ISO 639 language codes representing languages allowed by the collection
75
+ * jurisdictions:
76
+ * type: array
77
+ * items:
78
+ * type: string
79
+ * description: List of ISO 3166-2 country codes representing jurisdictions covered by the collection
80
+ * trackingPeriods:
81
+ * type: array
82
+ * items:
83
+ * type: object
84
+ * properties:
85
+ * startDate:
86
+ * type: string
87
+ * format: date
88
+ * description: The date when tracking started for this period
89
+ * schedule:
90
+ * type: string
91
+ * description: A cron expression defining when terms are tracked (e.g. "0 0 * * *" for daily at midnight)
92
+ * serverLocation:
93
+ * type: string
94
+ * description: The geographic location of the server used for tracking
95
+ * endDate:
96
+ * type: string
97
+ * format: date
98
+ * description: The date when tracking ended for this period
99
+ * governance:
100
+ * type: object
101
+ * additionalProperties:
102
+ * type: object
103
+ * properties:
104
+ * url:
105
+ * type: string
106
+ * format: uri
107
+ * description: URL to the entity's website
108
+ * logo:
109
+ * type: string
110
+ * format: uri
111
+ * description: URL to the entity's logo
112
+ * roles:
113
+ * type: array
114
+ * items:
115
+ * type: string
116
+ * enum: [host, administrator, curator, maintainer, sponsor]
117
+ * description: Roles of the entity within the governance
118
+ */
119
+ export default async function metadataRouter(collectionPath, services) {
120
+ const router = express.Router();
121
+
122
+ const STATIC_METADATA = yaml.load(await fs.readFile(path.join(collectionPath, METADATA_FILENAME), 'utf8'));
123
+ const { version: engineVersion } = JSON.parse(await fs.readFile(new URL(PACKAGE_JSON_PATH, import.meta.url)));
124
+
125
+ /**
126
+ * @swagger
127
+ * /metadata:
128
+ * get:
129
+ * summary: Get collection metadata
130
+ * tags: [Metadata]
131
+ * produces:
132
+ * - application/json
133
+ * responses:
134
+ * 200:
135
+ * description: Collection metadata
136
+ */
137
+ router.get('/metadata', (req, res) => {
138
+ const dynamicMetadata = {
139
+ totalServices: Object.keys(services).length,
140
+ totalTerms: Service.getNumberOfTerms(services),
141
+ engineVersion,
142
+ };
143
+
144
+ res.json({
145
+ ...STATIC_METADATA,
146
+ ...dynamicMetadata,
147
+ });
148
+ });
149
+
150
+ return router;
151
+ }
@@ -0,0 +1,89 @@
1
+ import fs from 'fs/promises';
2
+
3
+ import { expect } from 'chai';
4
+ import config from 'config';
5
+ import request from 'supertest';
6
+
7
+ import app from '../server.js';
8
+
9
+ const basePath = config.get('@opentermsarchive/engine.collection-api.basePath');
10
+ const { version: engineVersion } = JSON.parse(await fs.readFile(new URL('../../../package.json', import.meta.url)));
11
+
12
+ const EXPECTED_RESPONSE = {
13
+ totalServices: 7,
14
+ totalTerms: 8,
15
+ id: 'test',
16
+ name: 'test',
17
+ tagline: 'Test collection',
18
+ description: 'This is a test collection used for testing purposes.',
19
+ dataset: 'https://github.com/OpenTermsArchive/test-versions/releases',
20
+ declarations: 'https://github.com/OpenTermsArchive/test-declarations',
21
+ versions: 'https://github.com/OpenTermsArchive/test-versions',
22
+ snapshots: 'https://github.com/OpenTermsArchive/test-snapshots',
23
+ donations: null,
24
+ logo: 'https://opentermsarchive.org/images/logo/logo-open-terms-archive-black.png',
25
+ languages: [
26
+ 'en',
27
+ ],
28
+ jurisdictions: [
29
+ 'EU',
30
+ ],
31
+ governance: {
32
+ hosts: [
33
+ { name: 'Localhost' },
34
+ ],
35
+ administrators: [
36
+ {
37
+ name: 'Open Terms Archive',
38
+ url: 'https://opentermsarchive.org/',
39
+ logo: 'https://opentermsarchive.org/images/logo/logo-open-terms-archive-black.png',
40
+ },
41
+ ],
42
+ curators: [
43
+ {
44
+ name: 'Open Terms Archive',
45
+ url: 'https://opentermsarchive.org/',
46
+ logo: 'https://opentermsarchive.org/images/logo/logo-open-terms-archive-black.png',
47
+ },
48
+ ],
49
+ maintainers: [
50
+ {
51
+ name: 'Open Terms Archive',
52
+ url: 'https://opentermsarchive.org/',
53
+ logo: 'https://opentermsarchive.org/images/logo/logo-open-terms-archive-black.png',
54
+ },
55
+ ],
56
+ sponsors: [
57
+ {
58
+ name: 'Open Terms Archive',
59
+ url: 'https://opentermsarchive.org/',
60
+ logo: 'https://opentermsarchive.org/images/logo/logo-open-terms-archive-black.png',
61
+ },
62
+ ],
63
+ },
64
+ };
65
+
66
+ describe('Metadata API', () => {
67
+ describe('GET /metadata', () => {
68
+ let response;
69
+
70
+ before(async () => {
71
+ response = await request(app).get(`${basePath}/v1/metadata`);
72
+ });
73
+
74
+ it('responds with 200 status code', () => {
75
+ expect(response.status).to.equal(200);
76
+ });
77
+
78
+ it('responds with Content-Type application/json', () => {
79
+ expect(response.type).to.equal('application/json');
80
+ });
81
+
82
+ it('returns expected metadata object', () => {
83
+ expect(response.body).to.deep.equal({
84
+ ...EXPECTED_RESPONSE,
85
+ engineVersion,
86
+ });
87
+ });
88
+ });
89
+ });
@@ -1,9 +1,5 @@
1
1
  import express from 'express';
2
2
 
3
- import * as Services from '../../archivist/services/index.js';
4
-
5
- const services = await Services.load();
6
-
7
3
  /**
8
4
  * @swagger
9
5
  * tags:
@@ -54,106 +50,108 @@ const services = await Services.load();
54
50
  * items:
55
51
  * type: string
56
52
  */
57
- const router = express.Router();
53
+ export default function servicesRouter(services) {
54
+ const router = express.Router();
58
55
 
59
- /**
60
- * @swagger
61
- * /services:
62
- * get:
63
- * summary: Enumerate all services.
64
- * tags: [Services]
65
- * produces:
66
- * - application/json
67
- * responses:
68
- * 200:
69
- * description: A JSON array of all services.
70
- * content:
71
- * application/json:
72
- * schema:
73
- * type: array
74
- * items:
75
- * type: object
76
- * properties:
77
- * id:
78
- * type: string
79
- * description: The ID of the service.
80
- * name:
81
- * type: string
82
- * description: The name of the service.
83
- * terms:
84
- * type: array
85
- * description: The declared terms types for this service.
86
- * items:
87
- * type: object
88
- * properties:
89
- * type:
90
- * type: string
91
- * description: The type of terms.
92
- */
93
- router.get('/services', (req, res) => {
94
- res.status(200).json(Object.values(services).map(service => ({
95
- id: service.id,
96
- name: service.name,
97
- terms: service.getTermsTypes().map(type => ({ type })),
98
- })));
99
- });
56
+ /**
57
+ * @swagger
58
+ * /services:
59
+ * get:
60
+ * summary: Enumerate all services.
61
+ * tags: [Services]
62
+ * produces:
63
+ * - application/json
64
+ * responses:
65
+ * 200:
66
+ * description: A JSON array of all services.
67
+ * content:
68
+ * application/json:
69
+ * schema:
70
+ * type: array
71
+ * items:
72
+ * type: object
73
+ * properties:
74
+ * id:
75
+ * type: string
76
+ * description: The ID of the service.
77
+ * name:
78
+ * type: string
79
+ * description: The name of the service.
80
+ * terms:
81
+ * type: array
82
+ * description: The declared terms types for this service.
83
+ * items:
84
+ * type: object
85
+ * properties:
86
+ * type:
87
+ * type: string
88
+ * description: The type of terms.
89
+ */
90
+ router.get('/services', (req, res) => {
91
+ res.status(200).json(Object.values(services).map(service => ({
92
+ id: service.id,
93
+ name: service.name,
94
+ terms: service.getTermsTypes().map(type => ({ type })),
95
+ })));
96
+ });
100
97
 
101
- /**
102
- * @swagger
103
- * /service/{serviceId}:
104
- * get:
105
- * summary: Retrieve the declaration of a specific service through its ID.
106
- * tags: [Services]
107
- * produces:
108
- * - application/json
109
- * parameters:
110
- * - in: path
111
- * name: serviceId
112
- * description: The ID of the service.
113
- * schema:
114
- * type: string
115
- * required: true
116
- * examples:
117
- * service-1:
118
- * value: service-1
119
- * summary: Simple service ID
120
- * service-2:
121
- * value: Service 2!
122
- * summary: Service ID with spaces and special characters
123
- * responses:
124
- * 200:
125
- * description: The full JSON declaration of the service with the given ID.
126
- * content:
127
- * application/json:
128
- * schema:
129
- * $ref: '#/components/schemas/Service'
130
- * 404:
131
- * description: No service matching the provided ID is found.
132
- */
133
- router.get('/service/:serviceId', (req, res) => {
134
- const matchedServiceID = Object.keys(services).find(key => key.toLowerCase() === req.params.serviceId?.toLowerCase());
135
- const service = services[matchedServiceID];
98
+ /**
99
+ * @swagger
100
+ * /service/{serviceId}:
101
+ * get:
102
+ * summary: Retrieve the declaration of a specific service through its ID.
103
+ * tags: [Services]
104
+ * produces:
105
+ * - application/json
106
+ * parameters:
107
+ * - in: path
108
+ * name: serviceId
109
+ * description: The ID of the service.
110
+ * schema:
111
+ * type: string
112
+ * required: true
113
+ * examples:
114
+ * service-1:
115
+ * value: service-1
116
+ * summary: Simple service ID
117
+ * service-2:
118
+ * value: Service 2!
119
+ * summary: Service ID with spaces and special characters
120
+ * responses:
121
+ * 200:
122
+ * description: The full JSON declaration of the service with the given ID.
123
+ * content:
124
+ * application/json:
125
+ * schema:
126
+ * $ref: '#/components/schemas/Service'
127
+ * 404:
128
+ * description: No service matching the provided ID is found.
129
+ */
130
+ router.get('/service/:serviceId', (req, res) => {
131
+ const matchedServiceID = Object.keys(services).find(key => key.toLowerCase() === req.params.serviceId?.toLowerCase());
132
+ const service = services[matchedServiceID];
136
133
 
137
- if (!service) {
138
- res.status(404).send('Service not found');
134
+ if (!service) {
135
+ res.status(404).send('Service not found');
139
136
 
140
- return;
141
- }
137
+ return;
138
+ }
142
139
 
143
- res.status(200).json({
144
- id: service.id,
145
- name: service.name,
146
- terms: service.getTerms().map(terms => ({
147
- type: terms.type,
148
- sourceDocuments: terms.sourceDocuments.map(({ location, contentSelectors, insignificantContentSelectors, filters, executeClientScripts }) => ({
149
- location,
150
- contentSelectors,
151
- insignificantContentSelectors,
152
- executeClientScripts,
153
- filters: filters?.map(filter => filter.name),
140
+ res.status(200).json({
141
+ id: service.id,
142
+ name: service.name,
143
+ terms: service.getTerms().map(terms => ({
144
+ type: terms.type,
145
+ sourceDocuments: terms.sourceDocuments.map(({ location, contentSelectors, insignificantContentSelectors, filters, executeClientScripts }) => ({
146
+ location,
147
+ contentSelectors,
148
+ insignificantContentSelectors,
149
+ executeClientScripts,
150
+ filters: filters?.map(filter => filter.name),
151
+ })),
154
152
  })),
155
- })),
153
+ });
156
154
  });
157
- });
158
155
 
159
- export default router;
156
+ return router;
157
+ }
@@ -15,7 +15,7 @@ if (process.env.NODE_ENV !== 'test') {
15
15
 
16
16
  const BASE_PATH = `/${config.get('@opentermsarchive/engine.collection-api.basePath')}/v1`.replace(/\/\/+/g, '/'); // ensure there are no double slashes
17
17
 
18
- app.use(BASE_PATH, apiRouter(BASE_PATH));
18
+ app.use(BASE_PATH, await apiRouter(BASE_PATH));
19
19
  app.use(errorsMiddleware);
20
20
 
21
21
  const port = config.get('@opentermsarchive/engine.collection-api.port');