Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,23 @@ Then run
The tests save [Jest snapshots](https://jestjs.io/docs/snapshot-testing) of the expected output.

To update them, you'll need to run `yarn jest --updateSnapshot`.

## Event formats filtering

The `/events` endpoint supports inclusive and exclusive format filters via the `format` query parameter.

- To include specific formats, pass their IDs: `?format=<formatId1>,<formatId2>`.
- To exclude formats, prefix them with `!`: `?format=!<formatId1>,!<formatId2>`.

Additionally, the API supports human-friendly aliases for the
Prismic format IDs.

Examples:

- Use a label to include only `workshop` events:

`/events?format=workshop`

- Use a label to exclude `shopping` events:

`/events?format=!shopping`
82 changes: 80 additions & 2 deletions api/src/controllers/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ type QueryParams = {
sortOrder?: string;
aggregations?: string;
format?: string;
// `excludeFormat` comes from the `paramsValidator` when the public `format`
// query parameter contains negated values (e.g. `format=!exhibitions`). It
// is a comma-separated list of Prismic format IDs to exclude from results
// and is used to build an Elasticsearch `must_not` clause.
excludeFormat?: string;
audience?: string;
interpretation?: string;
location?: string;
Expand Down Expand Up @@ -94,8 +99,30 @@ const timespanValidator = queryValidator({
singleValue: true,
});

const formatAliasMap: Record<string, string> = {
exhibitions: EVENT_EXHIBITION_FORMAT_ID,
shopping: 'W-BjXhEAAASpa8Kb',
screening: 'W5fV0iYAACYAMxF9',
festival: 'W5fV5iYAACQAMxHb',
'send-workshop': 'W5ZIZyYAACMALDSB',
'walking-tour': 'WcKmcSsAACx_A8La',
'study-day': 'WcKmeisAALN8A8MB',
workshop: 'WcKmiysAACx_A8NR',
discussion: 'Wd-QYCcAACcAoiJS',
seminar: 'WlYVBiQAACcAWcu9',
'gallery-tour': 'WmYRpCQAACUAn-Ap',
symposium: 'Wn3NiioAACsAIdNK',
performance: 'Wn3Q3SoAACsAIeFI',
late: 'Ww_LyiEAAFOTlJ4-',
'chill-out': 'Xa7NJhAAAGpKv4uR',
installation: 'XiCd_BQAACQA36bS',
game: 'XiCdcxQAACIA36RO',
session: 'YzGUuBEAANURf3dM',
'relaxed-opening': 'ZCv01hQAAOAiVLeR',
};

const paramsValidator = (params: QueryParams): QueryParams => {
const { isAvailableOnline, filterOutExhibitions, ...rest } = params;
const { isAvailableOnline, filterOutExhibitions, format, ...rest } = params;

if (params.location)
locationsValidator({
Expand All @@ -111,7 +138,44 @@ const paramsValidator = (params: QueryParams): QueryParams => {
if (params.audience) prismicIdValidator(params.audience, 'audiences');
if (params.interpretation)
prismicIdValidator(params.interpretation, 'interpretations');
if (params.format) prismicIdValidator(params.format, 'formats');

const includeFormats: string[] = [];
const excludeFormats: string[] = [];

if (format) {
const rawFormats = format.split(',').map(value => value.trim());

rawFormats.forEach(value => {
if (!value) return;

if (value.startsWith('!')) {
const rawExclusion = value.slice(1).trim();

if (!rawExclusion) return;

// Normalise alias -> id; keep original value if no mapping exists
let normalized = rawExclusion;
if (formatAliasMap[normalized.toLowerCase()]) {
normalized = formatAliasMap[normalized.toLowerCase()];
}
excludeFormats.push(normalized);
} else {
let normalized = value;
if (formatAliasMap[normalized.toLowerCase()]) {
normalized = formatAliasMap[normalized.toLowerCase()];
}
includeFormats.push(normalized);
}
});

if (includeFormats.length > 0) {
prismicIdValidator(includeFormats.join(','), 'formats');
}

if (excludeFormats.length > 0) {
prismicIdValidator(excludeFormats.join(','), 'formats');
}
}

// Validate linkedWork parameter(s)
if (params.linkedWork) {
Expand All @@ -138,6 +202,10 @@ const paramsValidator = (params: QueryParams): QueryParams => {
// Anything else should remove the param from the query
return {
...rest,
...(includeFormats.length > 0 ? { format: includeFormats.join(',') } : {}),
...(excludeFormats.length > 0
? { excludeFormat: excludeFormats.join(',') }
: {}),
...(hasIsAvailableOnline ? { isAvailableOnline } : {}),
...(hasFilterOutExhibitions ? { filterOutExhibitions } : {}),
};
Expand Down Expand Up @@ -286,6 +354,16 @@ const eventsController = (clients: Clients, config: Config): EventsHandler => {
},
]
: []),
...(validParams.excludeFormat
? [
{
terms: {
'filter.format':
validParams.excludeFormat.split(','),
},
},
]
: []),
],
},
},
Expand Down
49 changes: 49 additions & 0 deletions api/test/__snapshots__/query.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,55 @@ exports[`addressables query makes the expected query to ES for a given set of qu
}
`;

exports[`addressables query makes the expected query to ES for multiple linkedWork parameters 1`] = `
{
"_source": [
"display",
],
"from": 0,
"index": "test-addressables",
"query": {
"bool": {
"must": [
{
"multi_match": {
"fields": [
"id",
"uid",
"query.title.*^100",
"query.contributors.*^10",
"query.contributors.keyword^100",
"query.body.*",
"query.description.*",
],
"minimum_should_match": "-25%",
"operator": "or",
"query": "sculpture",
"type": "cross_fields",
},
},
{
"terms": {
"query.linkedWorks": [
"work123",
"work456",
],
},
},
],
"must_not": [
{
"term": {
"query.tags": "delist",
},
},
],
},
},
"size": 20,
}
`;

exports[`articles query makes the expected query to ES for a given set of query parameters 1`] = `
{
"_source": [
Expand Down
34 changes: 18 additions & 16 deletions api/test/addressable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,53 +4,55 @@ describe('GET /all/:id', () => {
it('returns a document for the given ID', async () => {
const testId = 'Z-L8zREAACUAxTSz.exhibitions';
const testDoc = { title: 'test-addressable' };
const api = mockedApi([{ id: testId, display: testDoc }]);
const { agent } = mockedApi([{ id: testId, display: testDoc }]);

const response = await api.get(`/all/${encodeURIComponent(testId)}`);
const response = await agent.get(`/all/${encodeURIComponent(testId)}`);
expect(response.statusCode).toBe(200);
expect(response.body).toStrictEqual(testDoc);
});

it('returns a 404 if no document for the given ID exists', async () => {
const api = mockedApi([]);
const { agent } = mockedApi([]);

const response = await api.get(
const response = await agent.get(
`/all/${encodeURIComponent('Z-L8zREAACUAxTSz.exhibitions')}`
);
expect(response.statusCode).toBe(404);
});

it('returns a 400 for invalid content types', async () => {
const invalidTestId = 'ZX123.invalid-content-type';
const api = mockedApi([]);
const { agent } = mockedApi([]);

const response = await api.get(`/all/${encodeURIComponent(invalidTestId)}`);
const response = await agent.get(
`/all/${encodeURIComponent(invalidTestId)}`
);
expect(response.statusCode).toBe(400);
expect(response.body.description).toContain('Invalid content type');
});

it('returns a 400 for invalid id format (missing content type)', async () => {
const api = mockedApi([]);
const { agent } = mockedApi([]);

const response = await api.get(`/all/Z-L8zREAACUAxTSz`);
const response = await agent.get(`/all/Z-L8zREAACUAxTSz`);
expect(response.statusCode).toBe(400);
expect(response.body.description).toContain('Invalid id format');
});

it('returns a 400 for invalid Prismic ID format', async () => {
const api = mockedApi([]);
const { agent } = mockedApi([]);

const response = await api.get(
const response = await agent.get(
`/all/${encodeURIComponent('invalid%20id.exhibitions')}`
);
expect(response.statusCode).toBe(400);
expect(response.body.description).toContain('Invalid Prismic ID format');
});

it('returns a 400 for invalid exhibition highlight tour type', async () => {
const api = mockedApi([]);
const { agent } = mockedApi([]);

const response = await api.get(
const response = await agent.get(
`/all/${encodeURIComponent('Z-L8zREAACUAxTSz.exhibition-highlight-tours.invalid')}`
);
expect(response.statusCode).toBe(400);
Expand All @@ -60,19 +62,19 @@ describe('GET /all/:id', () => {
it('accepts valid exhibition highlight tour with audio type', async () => {
const testId = 'Z-L8zREAACUAxTSz.exhibition-highlight-tours.audio';
const testDoc = { title: 'test-tour' };
const api = mockedApi([{ id: testId, display: testDoc }]);
const { agent } = mockedApi([{ id: testId, display: testDoc }]);

const response = await api.get(`/all/${encodeURIComponent(testId)}`);
const response = await agent.get(`/all/${encodeURIComponent(testId)}`);
expect(response.statusCode).toBe(200);
expect(response.body).toStrictEqual(testDoc);
});

it('accepts valid exhibition highlight tour with BSL type', async () => {
const testId = 'Z-L8zREAACUAxTSz.exhibition-highlight-tours.bsl';
const testDoc = { title: 'test-bsl-tour' };
const api = mockedApi([{ id: testId, display: testDoc }]);
const { agent } = mockedApi([{ id: testId, display: testDoc }]);

const response = await api.get(`/all/${encodeURIComponent(testId)}`);
const response = await agent.get(`/all/${encodeURIComponent(testId)}`);
expect(response.statusCode).toBe(200);
expect(response.body).toStrictEqual(testDoc);
});
Expand Down
16 changes: 8 additions & 8 deletions api/test/addressables.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ describe('GET /all', () => {
title: `test doc ${i}`,
},
}));
const api = mockedApi(docs);
const { agent } = mockedApi(docs);

const response = await api.get(`/all`);
const response = await agent.get(`/all`);
expect(response.statusCode).toBe(200);
expect(response.body.results).toStrictEqual(docs.map(d => d.display));
});
Expand All @@ -22,17 +22,17 @@ describe('GET /all', () => {
display: { title: 'Document with works' },
},
];
const api = mockedApi(docs);
const { agent } = mockedApi(docs);

const response = await api.get(`/all?linkedWork=work123`);
const response = await agent.get(`/all?linkedWork=work123`);
expect(response.statusCode).toBe(200);
expect(response.body.results).toBeDefined();
});

it('returns 400 for invalid linkedWork format', async () => {
const api = mockedApi([]);
const { agent } = mockedApi([]);

const response = await api.get(`/all?linkedWork=invalid-work-id!`);
const response = await agent.get(`/all?linkedWork=invalid-work-id!`);
expect(response.statusCode).toBe(400);
expect(response.body.description).toContain('Invalid work ID format');
});
Expand All @@ -44,9 +44,9 @@ describe('GET /all', () => {
display: { title: 'Health article' },
},
];
const api = mockedApi(docs);
const { agent } = mockedApi(docs);

const response = await api.get(`/all?query=health&linkedWork=work123`);
const response = await agent.get(`/all?query=health&linkedWork=work123`);
expect(response.statusCode).toBe(200);
expect(response.body.results).toBeDefined();
});
Expand Down
8 changes: 4 additions & 4 deletions api/test/article.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,17 @@ describe('GET /articles/:id', () => {
it('returns a document for the given ID', async () => {
const testId = '123';
const testDoc = { title: 'test-article' };
const api = mockedApi([{ id: testId, display: testDoc }]);
const { agent } = mockedApi([{ id: testId, display: testDoc }]);

const response = await api.get(`/articles/${testId}`);
const response = await agent.get(`/articles/${testId}`);
expect(response.statusCode).toBe(200);
expect(response.body).toStrictEqual(testDoc);
});

it('returns a 404 if no document for the given ID exists', async () => {
const api = mockedApi([]);
const { agent } = mockedApi([]);

const response = await api.get(`/articles/123`);
const response = await agent.get(`/articles/123`);
expect(response.statusCode).toBe(404);
});
});
Loading