@opentermsarchive/engine 14.0.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.
- package/package.json +1 -1
- package/scripts/declarations/validate/index.mocha.js +8 -0
- package/src/archivist/recorder/repositories/git/dataMapper.js +4 -0
- package/src/archivist/recorder/repositories/git/git.js +90 -8
- package/src/archivist/recorder/repositories/git/index.js +91 -4
- package/src/archivist/recorder/repositories/git/index.test.js +288 -0
- package/src/archivist/recorder/repositories/interface.js +34 -0
- package/src/archivist/recorder/repositories/mongo/index.js +53 -0
- package/src/archivist/recorder/repositories/mongo/index.test.js +171 -0
- package/src/archivist/services/terms.js +12 -0
- package/src/archivist/services/terms.test.js +8 -0
- package/src/collection-api/middlewares/errors.js +1 -1
- package/src/collection-api/routes/index.js +2 -1
- package/src/collection-api/routes/services.js +34 -21
- package/src/collection-api/routes/versions.js +521 -27
- package/src/collection-api/routes/versions.test.js +655 -25
|
@@ -69,6 +69,15 @@ class RepositoryInterface {
|
|
|
69
69
|
throw new Error(`#findById method is not implemented in ${this.constructor.name}`);
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Find the metadata of the record that matches the given record ID, without loading its content
|
|
74
|
+
* @param {string} recordId - Record ID of the record to find
|
|
75
|
+
* @returns {Promise<Record>} Promise that will be resolved with the found record (without content) or null if none match the given ID
|
|
76
|
+
*/
|
|
77
|
+
async findMetadataById(recordId) {
|
|
78
|
+
throw new Error(`#findMetadataById method is not implemented in ${this.constructor.name}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
72
81
|
/**
|
|
73
82
|
* Find all records, in descending chronological order (newest first; opposite of #iterate)
|
|
74
83
|
* For performance reasons, the content of the records will not be loaded by default. Use #loadRecordContent to load the content of individual records
|
|
@@ -99,6 +108,20 @@ class RepositoryInterface {
|
|
|
99
108
|
throw new Error(`#findByService method is not implemented in ${this.constructor.name}`);
|
|
100
109
|
}
|
|
101
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Get the IDs locating a version within the history of its terms: the first (oldest) and last (newest) versions, as well as the immediately previous (older) and next (newer) ones
|
|
113
|
+
* These IDs are computed from a single deterministic chronological order (by fetch date, with the record ID as a stable tiebreaker), so navigating prev/next always round-trips
|
|
114
|
+
* @param {string} serviceId - Service ID of the version
|
|
115
|
+
* @param {string} termsType - Terms type of the version
|
|
116
|
+
* @param {string} versionId - ID of the version to locate within its terms history
|
|
117
|
+
* @param {object} [options] - Query options
|
|
118
|
+
* @param {boolean} [options.includeTechnicalUpgrades] - When false, exclude technical upgrade records from the sequence. Default: true
|
|
119
|
+
* @returns {Promise<{first: ?string, prev: ?string, next: ?string, last: ?string}>} Promise resolved with the related version IDs, each null when there is none
|
|
120
|
+
*/
|
|
121
|
+
async getNavigationIds(serviceId, termsType, versionId, options = {}) {
|
|
122
|
+
throw new Error(`#getNavigationIds method is not implemented in ${this.constructor.name}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
102
125
|
/**
|
|
103
126
|
* Find all records for a specific service and terms type, in descending chronological order
|
|
104
127
|
* For performance reasons, the content of the records will not be loaded by default. Use #loadRecordContent to load the content of individual records
|
|
@@ -152,6 +175,17 @@ class RepositoryInterface {
|
|
|
152
175
|
async loadRecordContent(record) {
|
|
153
176
|
throw new Error(`#loadRecordContent method is not implemented in ${this.constructor.name}`);
|
|
154
177
|
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Get the number of lines added and deleted by a record, relative to the previous state of the same terms
|
|
181
|
+
* This is an optional capability: backends that retain diffs (Git) override it, while backends that store full snapshots (MongoDB) inherit this default and report the statistics as unavailable
|
|
182
|
+
* @param {string} recordId - Record ID to get diff stats for
|
|
183
|
+
* @returns {Promise<{additions: ?number, deletions: ?number}>} Promise resolved with the number of added and deleted lines, each null when the backend cannot provide them
|
|
184
|
+
*/
|
|
185
|
+
// eslint-disable-next-line class-methods-use-this, no-unused-vars
|
|
186
|
+
getDiffStats(recordId) {
|
|
187
|
+
return { additions: null, deletions: null };
|
|
188
|
+
}
|
|
155
189
|
}
|
|
156
190
|
|
|
157
191
|
export default RepositoryInterface;
|
|
@@ -88,6 +88,19 @@ export default class MongoRepository extends RepositoryInterface {
|
|
|
88
88
|
return this.#toDomain(mongoDocument);
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
async findMetadataById(recordId) {
|
|
92
|
+
if (!ObjectId.isValid(recordId)) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const document = await this.collection.findOne(
|
|
97
|
+
{ _id: ObjectId.createFromHexString(recordId) },
|
|
98
|
+
{ projection: { content: 0 } },
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
return document ? this.#toDomain(document, { deferContentLoading: true }) : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
91
104
|
async findAll({ limit, offset, includeTechnicalUpgrades = true } = {}) {
|
|
92
105
|
const filter = includeTechnicalUpgrades ? {} : { isTechnicalUpgrade: { $ne: true } };
|
|
93
106
|
let query = this.collection.find(filter).project({ content: 0 }).sort({ fetchDate: -1 });
|
|
@@ -146,6 +159,46 @@ export default class MongoRepository extends RepositoryInterface {
|
|
|
146
159
|
.map(mongoDocument => this.#toDomain(mongoDocument, { deferContentLoading: true })));
|
|
147
160
|
}
|
|
148
161
|
|
|
162
|
+
async getNavigationIds(serviceId, termsType, versionId, { includeTechnicalUpgrades = true } = {}) {
|
|
163
|
+
const empty = { first: null, prev: null, next: null, last: null };
|
|
164
|
+
|
|
165
|
+
if (!ObjectId.isValid(versionId)) {
|
|
166
|
+
return empty;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const _id = ObjectId.createFromHexString(versionId);
|
|
170
|
+
const filter = { serviceId, termsType };
|
|
171
|
+
|
|
172
|
+
if (!includeTechnicalUpgrades) {
|
|
173
|
+
filter.isTechnicalUpgrade = { $ne: true };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const current = await this.collection.findOne({ ...filter, _id }, { projection: { fetchDate: 1 } });
|
|
177
|
+
|
|
178
|
+
if (!current) {
|
|
179
|
+
return empty;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const { fetchDate } = current;
|
|
183
|
+
const idOnly = { projection: { _id: 1 } };
|
|
184
|
+
|
|
185
|
+
// Deterministic total order (fetchDate, then _id) ascending; prev is the greatest record strictly before the current one, next the least strictly after.
|
|
186
|
+
// Using _id as a tiebreaker makes prev and next well-defined even for versions sharing the same fetch date, so navigation always round-trips.
|
|
187
|
+
const [ oldest, newest, previous, next ] = await Promise.all([
|
|
188
|
+
this.collection.find(filter, idOnly).sort({ fetchDate: 1, _id: 1 }).limit(1).next(),
|
|
189
|
+
this.collection.find(filter, idOnly).sort({ fetchDate: -1, _id: -1 }).limit(1).next(),
|
|
190
|
+
this.collection.find({ ...filter, $or: [{ fetchDate: { $lt: fetchDate } }, { fetchDate, _id: { $lt: _id } }] }, idOnly).sort({ fetchDate: -1, _id: -1 }).limit(1).next(),
|
|
191
|
+
this.collection.find({ ...filter, $or: [{ fetchDate: { $gt: fetchDate } }, { fetchDate, _id: { $gt: _id } }] }, idOnly).sort({ fetchDate: 1, _id: 1 }).limit(1).next(),
|
|
192
|
+
]);
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
first: oldest?._id?.toString() || null,
|
|
196
|
+
last: newest?._id?.toString() || null,
|
|
197
|
+
prev: previous?._id?.toString() || null,
|
|
198
|
+
next: next?._id?.toString() || null,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
149
202
|
count(serviceId, termsType) {
|
|
150
203
|
const filter = {};
|
|
151
204
|
|
|
@@ -988,6 +988,177 @@ describe('MongoRepository', () => {
|
|
|
988
988
|
});
|
|
989
989
|
});
|
|
990
990
|
|
|
991
|
+
describe('#getNavigationIds', () => {
|
|
992
|
+
let firstVersion;
|
|
993
|
+
let middleVersion;
|
|
994
|
+
let lastVersion;
|
|
995
|
+
|
|
996
|
+
before(async () => {
|
|
997
|
+
firstVersion = await subject.save(new Version({
|
|
998
|
+
serviceId: SERVICE_PROVIDER_ID,
|
|
999
|
+
termsType: TERMS_TYPE,
|
|
1000
|
+
content: 'first content',
|
|
1001
|
+
fetchDate: FETCH_DATE_EARLIER,
|
|
1002
|
+
snapshotIds: [SNAPSHOT_ID],
|
|
1003
|
+
}));
|
|
1004
|
+
|
|
1005
|
+
middleVersion = await subject.save(new Version({
|
|
1006
|
+
serviceId: SERVICE_PROVIDER_ID,
|
|
1007
|
+
termsType: TERMS_TYPE,
|
|
1008
|
+
content: 'middle content',
|
|
1009
|
+
fetchDate: FETCH_DATE,
|
|
1010
|
+
snapshotIds: [SNAPSHOT_ID],
|
|
1011
|
+
}));
|
|
1012
|
+
|
|
1013
|
+
lastVersion = await subject.save(new Version({
|
|
1014
|
+
serviceId: SERVICE_PROVIDER_ID,
|
|
1015
|
+
termsType: TERMS_TYPE,
|
|
1016
|
+
content: 'last content',
|
|
1017
|
+
fetchDate: FETCH_DATE_LATER,
|
|
1018
|
+
snapshotIds: [SNAPSHOT_ID],
|
|
1019
|
+
}));
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
after(() => subject.removeAll());
|
|
1023
|
+
|
|
1024
|
+
context('for the oldest version', () => {
|
|
1025
|
+
let navigationIds;
|
|
1026
|
+
|
|
1027
|
+
before(async () => { navigationIds = await subject.getNavigationIds(SERVICE_PROVIDER_ID, TERMS_TYPE, firstVersion.id); });
|
|
1028
|
+
|
|
1029
|
+
it('has no previous version', () => expect(navigationIds.prev).to.be.null);
|
|
1030
|
+
it('points to the next version', () => expect(navigationIds.next).to.equal(middleVersion.id));
|
|
1031
|
+
it('points to itself as first', () => expect(navigationIds.first).to.equal(firstVersion.id));
|
|
1032
|
+
it('points to the newest version as last', () => expect(navigationIds.last).to.equal(lastVersion.id));
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
context('for a middle version', () => {
|
|
1036
|
+
let navigationIds;
|
|
1037
|
+
|
|
1038
|
+
before(async () => { navigationIds = await subject.getNavigationIds(SERVICE_PROVIDER_ID, TERMS_TYPE, middleVersion.id); });
|
|
1039
|
+
|
|
1040
|
+
it('points to the previous version', () => expect(navigationIds.prev).to.equal(firstVersion.id));
|
|
1041
|
+
it('points to the next version', () => expect(navigationIds.next).to.equal(lastVersion.id));
|
|
1042
|
+
it('points to the oldest version as first', () => expect(navigationIds.first).to.equal(firstVersion.id));
|
|
1043
|
+
it('points to the newest version as last', () => expect(navigationIds.last).to.equal(lastVersion.id));
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
context('for the newest version', () => {
|
|
1047
|
+
let navigationIds;
|
|
1048
|
+
|
|
1049
|
+
before(async () => { navigationIds = await subject.getNavigationIds(SERVICE_PROVIDER_ID, TERMS_TYPE, lastVersion.id); });
|
|
1050
|
+
|
|
1051
|
+
it('points to the previous version', () => expect(navigationIds.prev).to.equal(middleVersion.id));
|
|
1052
|
+
it('has no next version', () => expect(navigationIds.next).to.be.null);
|
|
1053
|
+
it('points to itself as last', () => expect(navigationIds.last).to.equal(lastVersion.id));
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
context('when the version does not exist', () => {
|
|
1057
|
+
it('returns only null IDs', async () => {
|
|
1058
|
+
expect(await subject.getNavigationIds(SERVICE_PROVIDER_ID, TERMS_TYPE, 'ffffffffffffffffffffffff')).to.deep.equal({ first: null, prev: null, next: null, last: null });
|
|
1059
|
+
});
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
context('when a technical upgrade is recorded out of chronological order', () => {
|
|
1063
|
+
// A technical upgrade re-renders an old snapshot: it carries an OLD fetch date but is inserted last.
|
|
1064
|
+
// The navigation IDs are derived from a single chronological order, so prev/next stay exact inverses.
|
|
1065
|
+
const TECHNICAL_UPGRADE_DATE = new Date('2000-01-01T09:00:00.000Z'); // between firstVersion (06:00) and middleVersion (12:00)
|
|
1066
|
+
let technicalUpgrade;
|
|
1067
|
+
|
|
1068
|
+
before(async () => {
|
|
1069
|
+
technicalUpgrade = await subject.save(new Version({
|
|
1070
|
+
serviceId: SERVICE_PROVIDER_ID,
|
|
1071
|
+
termsType: TERMS_TYPE,
|
|
1072
|
+
content: 'technical upgrade content',
|
|
1073
|
+
fetchDate: TECHNICAL_UPGRADE_DATE,
|
|
1074
|
+
isTechnicalUpgrade: true,
|
|
1075
|
+
snapshotIds: [SNAPSHOT_ID],
|
|
1076
|
+
}));
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
it('orders it by its fetch date, not its insertion order', async () => {
|
|
1080
|
+
const navigationIds = await subject.getNavigationIds(SERVICE_PROVIDER_ID, TERMS_TYPE, technicalUpgrade.id);
|
|
1081
|
+
|
|
1082
|
+
expect(navigationIds.prev).to.equal(firstVersion.id);
|
|
1083
|
+
expect(navigationIds.next).to.equal(middleVersion.id);
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
it('keeps prev and next as exact inverses (round-trips)', async () => {
|
|
1087
|
+
const fromFirst = await subject.getNavigationIds(SERVICE_PROVIDER_ID, TERMS_TYPE, firstVersion.id);
|
|
1088
|
+
|
|
1089
|
+
expect(fromFirst.next).to.equal(technicalUpgrade.id);
|
|
1090
|
+
|
|
1091
|
+
const backFromUpgrade = await subject.getNavigationIds(SERVICE_PROVIDER_ID, TERMS_TYPE, fromFirst.next);
|
|
1092
|
+
|
|
1093
|
+
expect(backFromUpgrade.prev).to.equal(firstVersion.id);
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
it('can exclude technical upgrades from the sequence', async () => {
|
|
1097
|
+
const navigationIds = await subject.getNavigationIds(SERVICE_PROVIDER_ID, TERMS_TYPE, firstVersion.id, { includeTechnicalUpgrades: false });
|
|
1098
|
+
|
|
1099
|
+
expect(navigationIds.next).to.equal(middleVersion.id);
|
|
1100
|
+
});
|
|
1101
|
+
});
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
describe('#getNavigationIds with versions sharing the same fetch date', () => {
|
|
1105
|
+
// Distinct versions can share a fetch date; the _id tiebreaker must keep the order deterministic so prev/next still round-trip.
|
|
1106
|
+
let ids;
|
|
1107
|
+
|
|
1108
|
+
before(async () => {
|
|
1109
|
+
ids = [];
|
|
1110
|
+
for (const content of [ 'tie A', 'tie B', 'tie C' ]) {
|
|
1111
|
+
ids.push((await subject.save(new Version({ serviceId: SERVICE_PROVIDER_ID, termsType: TERMS_TYPE, content, fetchDate: FETCH_DATE, snapshotIds: [SNAPSHOT_ID] }))).id);
|
|
1112
|
+
}
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
after(() => subject.removeAll());
|
|
1116
|
+
|
|
1117
|
+
it('round-trips next then prev for every version', async () => {
|
|
1118
|
+
for (const id of ids) {
|
|
1119
|
+
const navigationIds = await subject.getNavigationIds(SERVICE_PROVIDER_ID, TERMS_TYPE, id);
|
|
1120
|
+
|
|
1121
|
+
if (navigationIds.next) {
|
|
1122
|
+
expect((await subject.getNavigationIds(SERVICE_PROVIDER_ID, TERMS_TYPE, navigationIds.next)).prev).to.equal(id);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
it('exposes all three versions as a single ordered chain', async () => {
|
|
1128
|
+
const head = await subject.getNavigationIds(SERVICE_PROVIDER_ID, TERMS_TYPE, ids[0]);
|
|
1129
|
+
const walked = new Set([head.first]);
|
|
1130
|
+
let cursor = head.first;
|
|
1131
|
+
|
|
1132
|
+
while (cursor) {
|
|
1133
|
+
walked.add(cursor);
|
|
1134
|
+
cursor = (await subject.getNavigationIds(SERVICE_PROVIDER_ID, TERMS_TYPE, cursor)).next;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
expect(walked).to.have.lengthOf(ids.length);
|
|
1138
|
+
});
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
describe('#getDiffStats', () => {
|
|
1142
|
+
// Diff statistics are an optional repository capability; MongoDB stores full snapshots rather than diffs, so it inherits the interface default that reports them as unavailable.
|
|
1143
|
+
let versionId;
|
|
1144
|
+
|
|
1145
|
+
before(async () => {
|
|
1146
|
+
({ id: versionId } = await subject.save(new Version({
|
|
1147
|
+
serviceId: SERVICE_PROVIDER_ID,
|
|
1148
|
+
termsType: TERMS_TYPE,
|
|
1149
|
+
content: CONTENT,
|
|
1150
|
+
fetchDate: FETCH_DATE,
|
|
1151
|
+
snapshotIds: [SNAPSHOT_ID],
|
|
1152
|
+
})));
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
after(() => subject.removeAll());
|
|
1156
|
+
|
|
1157
|
+
it('reports additions and deletions as unavailable', async () => {
|
|
1158
|
+
expect(await subject.getDiffStats(versionId)).to.deep.equal({ additions: null, deletions: null });
|
|
1159
|
+
});
|
|
1160
|
+
});
|
|
1161
|
+
|
|
991
1162
|
describe('#findLatest', () => {
|
|
992
1163
|
context('when there are records for the given service', () => {
|
|
993
1164
|
let lastSnapshotId;
|
|
@@ -13,6 +13,18 @@ export default class Terms {
|
|
|
13
13
|
return this.sourceDocuments.length > 1;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
get duplicateSourceDocuments() {
|
|
17
|
+
const seenLocations = new Set();
|
|
18
|
+
|
|
19
|
+
return this.sourceDocuments.filter(({ location }) => {
|
|
20
|
+
const isDuplicate = seenLocations.has(location);
|
|
21
|
+
|
|
22
|
+
seenLocations.add(location);
|
|
23
|
+
|
|
24
|
+
return isDuplicate;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
16
28
|
toPersistence() {
|
|
17
29
|
return {
|
|
18
30
|
name: this.service.name,
|
|
@@ -80,4 +80,12 @@ describe('Terms', () => {
|
|
|
80
80
|
expect(result).to.deep.equal(expectedResult);
|
|
81
81
|
});
|
|
82
82
|
});
|
|
83
|
+
|
|
84
|
+
describe('#duplicateSourceDocuments', () => {
|
|
85
|
+
it('returns the source documents that repeat a location', () => {
|
|
86
|
+
const terms = new Terms({ service, type: termsType, sourceDocuments: [ document1, document2 ] });
|
|
87
|
+
|
|
88
|
+
expect(terms.duplicateSourceDocuments).to.deep.equal([document2]);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
83
91
|
});
|
|
@@ -2,5 +2,5 @@ import logger from '../logger.js';
|
|
|
2
2
|
|
|
3
3
|
export default function errorsMiddleware(err, req, res, next) {
|
|
4
4
|
logger.error(err.stack);
|
|
5
|
-
res.status(500).json({ error:
|
|
5
|
+
res.status(500).json({ error: 'Internal Server Error' }); // Never echo internal error details: they can expose server internals such as filesystem paths
|
|
6
6
|
}
|
|
@@ -38,6 +38,7 @@ export default async function apiRouter(basePath) {
|
|
|
38
38
|
const collection = await getCollection();
|
|
39
39
|
const versionsStorageConfig = config.get('@opentermsarchive/engine.recorder.versions.storage');
|
|
40
40
|
const versionsRepository = await RepositoryFactory.create(versionsStorageConfig).initialize();
|
|
41
|
+
const snapshotsRepository = await RepositoryFactory.create(config.get('@opentermsarchive/engine.recorder.snapshots.storage')).initialize();
|
|
41
42
|
const feedConfig = config.get('@opentermsarchive/engine.collection-api.feed');
|
|
42
43
|
|
|
43
44
|
if (!collection.metadata?.id) {
|
|
@@ -50,7 +51,7 @@ export default async function apiRouter(basePath) {
|
|
|
50
51
|
|
|
51
52
|
router.use(await metadataRouter(collection, services));
|
|
52
53
|
router.use(servicesRouter(services));
|
|
53
|
-
router.use(versionsRouter(versionsRepository));
|
|
54
|
+
router.use(versionsRouter(versionsRepository, snapshotsRepository));
|
|
54
55
|
router.use(feedRouter(services, versionsRepository, versionsStorageConfig.type, feedConfig.limit, feedConfig.versionUrlTemplate));
|
|
55
56
|
|
|
56
57
|
return router;
|
|
@@ -9,6 +9,24 @@ import express from 'express';
|
|
|
9
9
|
* description: Services API
|
|
10
10
|
* components:
|
|
11
11
|
* schemas:
|
|
12
|
+
* ServiceListItem:
|
|
13
|
+
* type: object
|
|
14
|
+
* properties:
|
|
15
|
+
* id:
|
|
16
|
+
* type: string
|
|
17
|
+
* description: The ID of the service.
|
|
18
|
+
* name:
|
|
19
|
+
* type: string
|
|
20
|
+
* description: The name of the service.
|
|
21
|
+
* terms:
|
|
22
|
+
* type: array
|
|
23
|
+
* description: The declared terms types for this service.
|
|
24
|
+
* items:
|
|
25
|
+
* type: object
|
|
26
|
+
* properties:
|
|
27
|
+
* type:
|
|
28
|
+
* type: string
|
|
29
|
+
* description: The type of terms.
|
|
12
30
|
* Service:
|
|
13
31
|
* type: object
|
|
14
32
|
* description: Definition of a service and the agreements its provider sets forth. While the information is the same, the format differs from the JSON declaration files that are designed for readability by contributors.
|
|
@@ -51,6 +69,19 @@ import express from 'express';
|
|
|
51
69
|
* description: The names of filters to apply to the content.
|
|
52
70
|
* items:
|
|
53
71
|
* type: string
|
|
72
|
+
* ErrorResponse:
|
|
73
|
+
* type: object
|
|
74
|
+
* properties:
|
|
75
|
+
* error:
|
|
76
|
+
* type: string
|
|
77
|
+
* description: Error message.
|
|
78
|
+
* responses:
|
|
79
|
+
* NotFoundError:
|
|
80
|
+
* description: Resource not found.
|
|
81
|
+
* content:
|
|
82
|
+
* application/json:
|
|
83
|
+
* schema:
|
|
84
|
+
* $ref: '#/components/schemas/ErrorResponse'
|
|
54
85
|
*/
|
|
55
86
|
export default function servicesRouter(services) {
|
|
56
87
|
const router = express.Router();
|
|
@@ -71,23 +102,7 @@ export default function servicesRouter(services) {
|
|
|
71
102
|
* schema:
|
|
72
103
|
* type: array
|
|
73
104
|
* items:
|
|
74
|
-
*
|
|
75
|
-
* properties:
|
|
76
|
-
* id:
|
|
77
|
-
* type: string
|
|
78
|
-
* description: The ID of the service.
|
|
79
|
-
* name:
|
|
80
|
-
* type: string
|
|
81
|
-
* description: The name of the service.
|
|
82
|
-
* terms:
|
|
83
|
-
* type: array
|
|
84
|
-
* description: The declared terms types for this service.
|
|
85
|
-
* items:
|
|
86
|
-
* type: object
|
|
87
|
-
* properties:
|
|
88
|
-
* type:
|
|
89
|
-
* type: string
|
|
90
|
-
* description: The type of terms.
|
|
105
|
+
* $ref: '#/components/schemas/ServiceListItem'
|
|
91
106
|
*/
|
|
92
107
|
router.get('/services', (req, res) => {
|
|
93
108
|
res.status(200).json(Object.values(services).map(service => ({
|
|
@@ -127,15 +142,13 @@ export default function servicesRouter(services) {
|
|
|
127
142
|
* schema:
|
|
128
143
|
* $ref: '#/components/schemas/Service'
|
|
129
144
|
* 404:
|
|
130
|
-
*
|
|
145
|
+
* $ref: '#/components/responses/NotFoundError'
|
|
131
146
|
*/
|
|
132
147
|
router.get('/service/:serviceId', (req, res) => {
|
|
133
148
|
const service = Object.hasOwn(services, req.params.serviceId) ? services[req.params.serviceId] : null;
|
|
134
149
|
|
|
135
150
|
if (!service) {
|
|
136
|
-
res.status(404).
|
|
137
|
-
|
|
138
|
-
return;
|
|
151
|
+
return res.status(404).json({ error: 'Service not found' });
|
|
139
152
|
}
|
|
140
153
|
|
|
141
154
|
res.status(200).json({
|