Skip to content

Commit e73d362

Browse files
committed
Add service-scoped feed endpoint
1 parent 18a5181 commit e73d362

5 files changed

Lines changed: 172 additions & 5 deletions

File tree

src/collection-api/routes/feed.js

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { COMMIT_MESSAGE_PREFIXES } from '../../archivist/recorder/repositories/g
66
import { toISODateWithoutMilliseconds } from '../../archivist/utils/date.js';
77

88
import versionsRepository, { storageConfig } from './versionsRepository.js';
9+
import { findServiceCaseInsensitive } from './utils.js';
910

1011
const TAG_AUTHORITY = 'opentermsarchive.org,2026';
1112
const FEED_AUTHOR_NAME = 'OTA-Bot';
@@ -108,13 +109,14 @@ function render(document) {
108109
}
109110

110111
/**
111-
* @returns {express.Router} The router instance
112+
* @param {object} services The services to be exposed by the API
113+
* @returns {express.Router} The router instance
112114
* @swagger
113115
* tags:
114116
* name: Feeds
115117
* description: Atom feeds of version changes
116118
*/
117-
export default function feedRouter() {
119+
export default function feedRouter(services) {
118120
const router = express.Router();
119121

120122
/**
@@ -145,5 +147,48 @@ export default function feedRouter() {
145147
sendAtom(res, render(document));
146148
});
147149

150+
/**
151+
* @swagger
152+
* /feed/{serviceId}:
153+
* get:
154+
* summary: Atom feed of the latest version changes scoped to a single service.
155+
* tags: [Feeds]
156+
* produces:
157+
* - application/atom+xml
158+
* parameters:
159+
* - in: path
160+
* name: serviceId
161+
* description: The ID of the service. Case-insensitive.
162+
* schema:
163+
* type: string
164+
* required: true
165+
* responses:
166+
* 200:
167+
* description: An Atom 1.0 feed listing the latest version records for the given service, newest first.
168+
* content:
169+
* application/atom+xml:
170+
* schema:
171+
* type: string
172+
* 404:
173+
* description: No service matching the provided ID is found.
174+
*/
175+
router.get('/feed/:serviceId', async (req, res) => {
176+
const service = findServiceCaseInsensitive(services, req.params.serviceId);
177+
178+
if (!service) {
179+
return res.status(404).send('Service not found');
180+
}
181+
182+
const collection = await getCollection();
183+
const baseUrl = buildAbsoluteBaseUrl(req);
184+
const selfHref = `${baseUrl}/feed/${encodeURIComponent(service.id)}`;
185+
const feedId = `tag:${TAG_AUTHORITY}:feed:${collection.metadata?.id}:${service.id}`;
186+
187+
const versions = await versionsRepository.findRecent(DEFAULT_LIMIT, { serviceId: service.id });
188+
const document = buildFeedDocument({ collection, selfHref, feedId, versions, baseUrl });
189+
190+
return sendAtom(res, render(document));
191+
});
192+
148193
return router;
149194
}

src/collection-api/routes/feed.test.js

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,4 +228,120 @@ describe('Feed API', () => {
228228
});
229229
});
230230
});
231+
232+
describe('GET /feed/:serviceId', () => {
233+
const SERVICE = 'service_without_history';
234+
const OTHER_SERVICE = 'service_with_history';
235+
const TERMS = 'Terms of Service';
236+
237+
let repository;
238+
239+
before(async function () {
240+
this.timeout(5000);
241+
repository = RepositoryFactory.create(storageConfig);
242+
await repository.initialize();
243+
244+
await repository.save(new Version({
245+
serviceId: SERVICE,
246+
termsType: TERMS,
247+
content: 'c1',
248+
fetchDate: new Date('2024-01-01T00:00:00Z'),
249+
snapshotIds: ['s1'],
250+
}));
251+
await repository.save(new Version({
252+
serviceId: SERVICE,
253+
termsType: TERMS,
254+
content: 'c2',
255+
fetchDate: new Date('2024-02-01T00:00:00Z'),
256+
snapshotIds: ['s2'],
257+
}));
258+
await repository.save(new Version({
259+
serviceId: OTHER_SERVICE,
260+
termsType: TERMS,
261+
content: 'c3',
262+
fetchDate: new Date('2024-03-01T00:00:00Z'),
263+
snapshotIds: ['s3'],
264+
}));
265+
});
266+
267+
after(() => repository.removeAll());
268+
269+
context('when the service exists and has versions', () => {
270+
let response;
271+
272+
before(async () => {
273+
response = await request.get(`${basePath}/v1/feed/${encodeURIComponent(SERVICE)}`);
274+
});
275+
276+
it('responds with 200', () => {
277+
expect(response.status).to.equal(200);
278+
});
279+
280+
it('responds with Content-Type application/atom+xml', () => {
281+
expect(response.headers['content-type']).to.match(/^application\/atom\+xml/);
282+
});
283+
284+
it('includes only entries for that service', () => {
285+
const serviceTerms = [...response.text.matchAll(/scheme="tag:opentermsarchive.org,2026:scheme:service"[^/]*term="([^"]+)"/g)]
286+
.concat([...response.text.matchAll(/term="([^"]+)"[^/]*scheme="tag:opentermsarchive.org,2026:scheme:service"/g)])
287+
.map(match => match[1]);
288+
289+
expect(serviceTerms).to.not.be.empty;
290+
291+
for (const term of serviceTerms) {
292+
expect(term).to.equal(SERVICE);
293+
}
294+
});
295+
296+
it('has a feed id including the service id', () => {
297+
expect(extractTag(response.text, 'id')).to.equal(`tag:opentermsarchive.org,2026:feed:test:${SERVICE}`);
298+
});
299+
300+
it('has a self link pointing to the service-scoped feed endpoint', () => {
301+
const href = response.text.match(/<link[^>]*rel="self"[^>]*href="([^"]+)"/)[1];
302+
303+
expect(href).to.match(new RegExp(`/feed/${SERVICE}$`));
304+
});
305+
});
306+
307+
context('when the service exists but has no versions', () => {
308+
let response;
309+
310+
before(async () => {
311+
response = await request.get(`${basePath}/v1/feed/${encodeURIComponent('service_with_filters_history')}`);
312+
});
313+
314+
it('responds with 200', () => {
315+
expect(response.status).to.equal(200);
316+
});
317+
318+
it('returns an empty feed (no entries)', () => {
319+
expect(response.text).to.not.include('<entry>');
320+
});
321+
});
322+
323+
context('when the service does not exist', () => {
324+
let response;
325+
326+
before(async () => {
327+
response = await request.get(`${basePath}/v1/feed/DoesNotExist`);
328+
});
329+
330+
it('responds with 404', () => {
331+
expect(response.status).to.equal(404);
332+
});
333+
});
334+
335+
context('when the serviceId uses different casing', () => {
336+
let response;
337+
338+
before(async () => {
339+
response = await request.get(`${basePath}/v1/feed/${encodeURIComponent(SERVICE.toUpperCase())}`);
340+
});
341+
342+
it('still resolves to the service (case-insensitive)', () => {
343+
expect(response.status).to.equal(200);
344+
});
345+
});
346+
});
231347
});

src/collection-api/routes/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export default async function apiRouter(basePath) {
3838
router.use(await metadataRouter(collection, services));
3939
router.use(servicesRouter(services));
4040
router.use(versionsRouter);
41-
router.use(feedRouter());
41+
router.use(feedRouter(services));
4242

4343
return router;
4444
}

src/collection-api/routes/services.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import express from 'express';
22

3+
import { findServiceCaseInsensitive } from './utils.js';
4+
35
/**
46
* @param {object} services The services to be exposed by the API
57
* @returns {express.Router} The router instance
@@ -130,8 +132,7 @@ export default function servicesRouter(services) {
130132
* description: No service matching the provided ID is found.
131133
*/
132134
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];
135+
const service = findServiceCaseInsensitive(services, req.params.serviceId);
135136

136137
if (!service) {
137138
res.status(404).send('Service not found');

src/collection-api/routes/utils.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export function findServiceCaseInsensitive(services, serviceId) {
2+
const matched = Object.keys(services).find(key => key.toLowerCase() === serviceId?.toLowerCase());
3+
4+
return matched ? services[matched] : null;
5+
}

0 commit comments

Comments
 (0)