Skip to content

Commit 891151a

Browse files
committed
Add service and terms type scoped feed endpoint
1 parent e73d362 commit 891151a

2 files changed

Lines changed: 194 additions & 0 deletions

File tree

src/collection-api/routes/feed.js

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,5 +190,60 @@ export default function feedRouter(services) {
190190
return sendAtom(res, render(document));
191191
});
192192

193+
/**
194+
* @swagger
195+
* /feed/{serviceId}/{termsType}:
196+
* get:
197+
* summary: Atom feed of the latest version changes scoped to a service and terms type.
198+
* tags: [Feeds]
199+
* produces:
200+
* - application/atom+xml
201+
* parameters:
202+
* - in: path
203+
* name: serviceId
204+
* description: The ID of the service. Case-insensitive.
205+
* schema:
206+
* type: string
207+
* required: true
208+
* - in: path
209+
* name: termsType
210+
* description: The terms type declared by the service (e.g. "Terms of Service", "Privacy Policy").
211+
* schema:
212+
* type: string
213+
* required: true
214+
* responses:
215+
* 200:
216+
* description: An Atom 1.0 feed listing the latest version records for the given service and terms type, newest first.
217+
* content:
218+
* application/atom+xml:
219+
* schema:
220+
* type: string
221+
* 404:
222+
* description: Either the service ID does not match any service or the terms type is not declared by that service.
223+
*/
224+
router.get('/feed/:serviceId/:termsType', async (req, res) => {
225+
const service = findServiceCaseInsensitive(services, req.params.serviceId);
226+
227+
if (!service) {
228+
return res.status(404).send('Service not found');
229+
}
230+
231+
const { termsType } = req.params;
232+
233+
if (!service.getTermsTypes().includes(termsType)) {
234+
return res.status(404).send('Terms type not found for this service');
235+
}
236+
237+
const collection = await getCollection();
238+
const baseUrl = buildAbsoluteBaseUrl(req);
239+
const selfHref = `${baseUrl}/feed/${encodeURIComponent(service.id)}/${encodeURIComponent(termsType)}`;
240+
const feedId = `tag:${TAG_AUTHORITY}:feed:${collection.metadata?.id}:${service.id}:${termsType}`;
241+
242+
const versions = await versionsRepository.findRecent(DEFAULT_LIMIT, { serviceId: service.id, termsType });
243+
const document = buildFeedDocument({ collection, selfHref, feedId, versions, baseUrl });
244+
245+
return sendAtom(res, render(document));
246+
});
247+
193248
return router;
194249
}

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

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,4 +344,143 @@ describe('Feed API', () => {
344344
});
345345
});
346346
});
347+
348+
describe('XML escaping and URL encoding', () => {
349+
const SERVICE = 'Service B!';
350+
const TERMS = 'Privacy Policy';
351+
const FETCH_DATE = new Date('2024-05-15T10:00:00Z');
352+
353+
let response;
354+
let repository;
355+
356+
before(async function () {
357+
this.timeout(5000);
358+
repository = RepositoryFactory.create(storageConfig);
359+
await repository.initialize();
360+
361+
await repository.save(new Version({
362+
serviceId: SERVICE,
363+
termsType: TERMS,
364+
content: 'content with & and <tags>',
365+
fetchDate: FETCH_DATE,
366+
snapshotIds: ['s_escape'],
367+
}));
368+
369+
response = await request.get(`${basePath}/v1/feed/${encodeURIComponent(SERVICE)}/${encodeURIComponent(TERMS)}`);
370+
});
371+
372+
after(() => repository.removeAll());
373+
374+
it('responds with 200', () => {
375+
expect(response.status).to.equal(200);
376+
});
377+
378+
it('URL-encodes spaces and special characters in the self link href', () => {
379+
const href = response.text.match(/<link[^>]*rel="self"[^>]*href="([^"]+)"/)[1];
380+
381+
expect(href).to.include('Service%20B!');
382+
expect(href).to.include('Privacy%20Policy');
383+
expect(href).to.not.include('Service B!');
384+
});
385+
386+
it('URL-encodes spaces and special characters in entry alternate links', () => {
387+
const href = response.text.match(/<link[^>]*rel="alternate"[^>]*href="([^"]+)"/)[1];
388+
389+
expect(href).to.include('Service%20B!');
390+
expect(href).to.include('Privacy%20Policy');
391+
});
392+
});
393+
394+
describe('GET /feed/:serviceId/:termsType', () => {
395+
const SERVICE = 'service_without_history';
396+
const TERMS = 'Terms of Service';
397+
const UNKNOWN_TERMS = 'Imprint';
398+
399+
let repository;
400+
401+
before(async function () {
402+
this.timeout(5000);
403+
repository = RepositoryFactory.create(storageConfig);
404+
await repository.initialize();
405+
406+
await repository.save(new Version({
407+
serviceId: SERVICE,
408+
termsType: TERMS,
409+
content: 'first',
410+
fetchDate: new Date('2024-01-01T00:00:00Z'),
411+
snapshotIds: ['s1'],
412+
}));
413+
await repository.save(new Version({
414+
serviceId: SERVICE,
415+
termsType: TERMS,
416+
content: 'updated',
417+
fetchDate: new Date('2024-02-01T00:00:00Z'),
418+
snapshotIds: ['s2'],
419+
}));
420+
});
421+
422+
after(() => repository.removeAll());
423+
424+
context('when the service and terms type match', () => {
425+
let response;
426+
427+
before(async () => {
428+
response = await request.get(`${basePath}/v1/feed/${encodeURIComponent(SERVICE)}/${encodeURIComponent(TERMS)}`);
429+
});
430+
431+
it('responds with 200', () => {
432+
expect(response.status).to.equal(200);
433+
});
434+
435+
it('includes entries for the combination', () => {
436+
const entries = response.text.match(/<entry>/g) || [];
437+
438+
expect(entries.length).to.be.at.least(1);
439+
});
440+
441+
it('entries only have the expected terms type', () => {
442+
const termsTypeTerms = [...response.text.matchAll(/<category[^/]*scheme="tag:opentermsarchive.org,2026:scheme:terms-type"[^/]*term="([^"]+)"/g)]
443+
.concat([...response.text.matchAll(/<category[^/]*term="([^"]+)"[^/]*scheme="tag:opentermsarchive.org,2026:scheme:terms-type"/g)])
444+
.map(match => match[1]);
445+
446+
for (const term of termsTypeTerms) {
447+
expect(term).to.equal(TERMS);
448+
}
449+
});
450+
451+
it('has a feed id that includes both service and terms type', () => {
452+
expect(extractTag(response.text, 'id')).to.equal(`tag:opentermsarchive.org,2026:feed:test:${SERVICE}:${TERMS}`);
453+
});
454+
455+
it('has a self link pointing to the combination endpoint', () => {
456+
const href = response.text.match(/<link[^>]*rel="self"[^>]*href="([^"]+)"/)[1];
457+
458+
expect(href).to.match(new RegExp(`/feed/${SERVICE}/${encodeURIComponent(TERMS)}$`));
459+
});
460+
});
461+
462+
context('when the service exists but does not declare the terms type', () => {
463+
let response;
464+
465+
before(async () => {
466+
response = await request.get(`${basePath}/v1/feed/${encodeURIComponent(SERVICE)}/${encodeURIComponent(UNKNOWN_TERMS)}`);
467+
});
468+
469+
it('responds with 404', () => {
470+
expect(response.status).to.equal(404);
471+
});
472+
});
473+
474+
context('when the service does not exist', () => {
475+
let response;
476+
477+
before(async () => {
478+
response = await request.get(`${basePath}/v1/feed/DoesNotExist/${encodeURIComponent(TERMS)}`);
479+
});
480+
481+
it('responds with 404', () => {
482+
expect(response.status).to.equal(404);
483+
});
484+
});
485+
});
347486
});

0 commit comments

Comments
 (0)