Skip to content

Commit

Permalink
feat(sports): add dynamic CMS configured redirect using middleware
Browse files Browse the repository at this point in the history
The redirects are fetched periodically from CMS's GraphQL endpoint for
siteSettings.redirects. The redirects' cache is in-memory and per pod,
and the cache is refreshed from CMS by default between 6–9 minutes
(i.e. 5–10min interval but a bit tighter and under 10min).

Redirects' cache updating interval can be configured using environment
variables:
 - MIN_REDIRECTS_UPDATE_INTERVAL_MS (defaults to 6min in milliseconds)
 - MAX_REDIRECTS_UPDATE_INTERVAL_MS (defaults to 9min in millseconds)

Also update generated GraphQL schemas i.e. ran:
```bash
cd apps/sports-helsinki/
yarn generate:graphql
cp ./src/components/domain/graphql/generated/graphql.tsx \
   ../../packages/components/src/types/generated/
cd ../../
npx prettier ./packages/components/src/types/generated/graphql.tsx -w
rm -fr ./apps/sports-helsinki/src/components/
```
and as a consequence disambiguate conflicting PageInfo types,
PageInfo, PageUriInfo and VenueProxyPageInfo.

refs LIIKUNTA-655
  • Loading branch information
karisal-anders committed Sep 6, 2024
1 parent c0d9b08 commit 6170274
Show file tree
Hide file tree
Showing 27 changed files with 2,728 additions and 338 deletions.
1 change: 1 addition & 0 deletions apps/events-helsinki/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"dotenv": "^16.3.1",
"file-saver": "^2.0.5",
"graphql": "16.7.1",
"graphql-request": "7.1.0",
"hds-react": "^3.9.0",
"i18next": "23.2.3",
"ics": "^3.2.0",
Expand Down
1 change: 1 addition & 0 deletions apps/hobbies-helsinki/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"dotenv": "^16.3.1",
"file-saver": "^2.0.5",
"graphql": "16.7.1",
"graphql-request": "7.1.0",
"hds-react": "^3.9.0",
"i18next": "23.2.3",
"ics": "^3.2.0",
Expand Down
1 change: 1 addition & 0 deletions apps/sports-helsinki/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"dotenv": "^16.3.1",
"file-saver": "^2.0.5",
"graphql": "16.7.1",
"graphql-request": "7.1.0",
"hds-react": "^3.9.0",
"i18next": "23.2.3",
"ics": "^3.2.0",
Expand Down
1 change: 1 addition & 0 deletions apps/sports-helsinki/redirectCampaignRoutes.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Ignore prettier in order to preserve readability of the routes:
// prettier-ignore
// @deprecated since implementation of dynamic CMS configured redirects
const redirectRoutes = {
// Finnish locale versions
'/ulkokuntosaliohjaukset': '/fi/pages/ohjattu-liikunta/ohjattu-toiminta-ulkokuntosaleilla',
Expand Down
10 changes: 10 additions & 0 deletions apps/sports-helsinki/src/edge-runtime-compatible/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
## Edge runtime compatible source code

Basically for [Next.js's middleware compatibility](https://nextjs.org/docs/pages/building-your-application/routing/middleware#runtime):

> Middleware currently only supports the Edge runtime.
See

- https://nextjs.org/docs/pages/api-reference/edge
- https://nextjs.org/docs/pages/building-your-application/routing/middleware
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { isObject } from '../typeguards';

describe('isObject returns true', () => {
it.each([
{},
{ 1: undefined },
{ key: 'value' },
{ key: 'value', anotherKey: 'another value' },
{ a: { b: 1, 2: { 3: [4, 5, 6] } } },
])('%s', (value) => {
expect(isObject(value)).toBe(true);
});
});

describe('isObject returns false', () => {
it.each([
null,
undefined,
NaN,
0,
'',
' ',
'test',
1,
123,
123.5,
[],
['non-empty array'],
Array(1).fill(1),
new Set(),
new Set([1, 2, 3]),
new Map(),
new Map([['key', 'value']]),
Symbol('symbol'),
BigInt(123),
])('%s', (value) => {
expect(isObject(value)).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { isStringOrNull } from '../typeguards';

describe('isStringOrNull returns true', () => {
it.each(['', ' ', 'test', '匹', null])('%s', (value) => {
expect(isStringOrNull(value)).toBe(true);
});
});

describe('isStringOrNull returns false', () => {
it.each([
{},
undefined,
NaN,
0,
1,
123,
123.5,
{ 1: undefined },
{ key: 'value' },
{ key: 'value', anotherKey: 'another value' },
{ a: { b: 1, 2: { 3: [4, 5, 6] } } },
[],
['non-empty array'],
Array(1).fill(1),
new Set(),
new Set([1, 2, 3]),
new Map(),
new Map([['key', 'value']]),
Symbol('symbol'),
BigInt(123),
])('%s', (value) => {
expect(isStringOrNull(value)).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { isValidRedirectAddress } from '../isValidRedirectAddress';

describe('isValidRedirectAddress returns true', () => {
it.each([
'/',
'/questions/198606/can-i-use-commas-in-a-url',
'/fi/search?searchType=Venue&text=test+a+text+search&venueOrderBy=wheelchair',
'/wiki/File:Artistic_swimming_women%27s_team_medal_ceremony_at_Tokyo_2020.jpg',
'/wiki/2020年夏季奧林匹克運動會團體韻律泳比賽',
'/wiki/Καλλιτεχνική_κολύμβηση_στους_Θερινούς_Ολυμπιακούς_Αγώνες_2020_–_Ομαδικό_γυναικών#Αποτελέσματα',
'/auth/realms/helsinki-tunnistus/protocol/openid-connect/auth?client_id=kukkuu-ui&redirect_uri=' +
'https%3A%2F%2Fkummilapset.hel.fi%2Fcallback&response_type=code&scope=openid+profile+email&state=' +
'5df5f78691853f7ef4f5db2d02fb0e20&code_challenge=ZbOcQqOA-lLNB93o_OnaCJo7QcINCfx-cfVbMrZwDFD&' +
'code_challenge_method=S256&response_mode=query',
'/fi/أطفال-الثقافة/',
])('%s', (pathname) => {
expect(isValidRedirectAddress(pathname)).toBe(true);
});
});

describe('isValidRedirectAddress returns false', () => {
it.each([
'',
' ',
' /',
'/ ',
' / ',
'\n/',
'//',
'/`',
'https://example.org/',
'https://liikunta.hel.fi/search',
])('%s', (pathname) => {
expect(isValidRedirectAddress(pathname)).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { parseRedirects } from '../parseRedirects';
import type { AppLanguage, Redirects } from '../types';

let consoleWarningSpy: jest.SpyInstance;

beforeEach(() => {
consoleWarningSpy = jest.spyOn(console, 'warn').mockImplementation();
});

afterEach(() => {
jest.clearAllMocks();
});

describe('parseRedirects', () => {
type TestInput = {
encodedRedirects: string | null | undefined;
language: AppLanguage;
expectedOutput: Redirects;
expectedWarnings: string[];
};

it.each<TestInput>([
{
encodedRedirects: null,
language: 'fi',
expectedOutput: {},
expectedWarnings: [],
},
{
encodedRedirects: undefined,
language: 'fi',
expectedOutput: {},
expectedWarnings: [],
},
{
encodedRedirects: 'null',
language: 'fi',
expectedOutput: {},
expectedWarnings: [],
},
{
encodedRedirects: '[]',
language: 'fi',
expectedOutput: {},
expectedWarnings: [],
},
{
encodedRedirects: '[{"/a": "/b"}]',
language: 'fi',
expectedOutput: { '/a': '/b', '/fi/a': '/b' },
expectedWarnings: [],
},
{
encodedRedirects:
'[{"/a": "/b"},' +
'{"": ""},' +
'{"invalid-from-path": "/valid-to-path"},' +
'{"/test": "https://example.org/other-site/", "/c": "/d"},' +
'{"/valid-from-path": "invalid-to-path"},' +
'{"invalid-from-path": "invalid-to-path"}]',
language: 'fi',
expectedOutput: { '/a': '/b', '/fi/a': '/b', '/c': '/d', '/fi/c': '/d' },
expectedWarnings: [
'Ignoring invalid fi redirect: invalid-from-path -> /valid-to-path',
'Ignoring invalid fi redirect: /test -> https://example.org/other-site/',
'Ignoring invalid fi redirect: /valid-from-path -> invalid-to-path',
'Ignoring invalid fi redirect: invalid-from-path -> invalid-to-path',
],
},
{
encodedRedirects: '[{"/a": "/b"}]',
language: 'sv',
expectedOutput: { '/a': '/b', '/sv/a': '/b' },
expectedWarnings: [],
},
{
encodedRedirects: '[{"/a": "/b"}]',
language: 'en',
expectedOutput: { '/a': '/b', '/en/a': '/b' },
expectedWarnings: [],
},
{
encodedRedirects:
'[{"/wiki/ακ#Απ": "/中-cn/to_go/2020年?search=with+text&type=new#info"}]',
language: 'en',
expectedOutput: {
'/en/wiki/ακ#Απ': '/中-cn/to_go/2020年?search=with+text&type=new#info',
'/wiki/ακ#Απ': '/中-cn/to_go/2020年?search=with+text&type=new#info',
},
expectedWarnings: [],
},
{
encodedRedirects: '[{"/a": "/b"}, {"/c": "/d"}]',
language: 'fi',
expectedOutput: {
'/a': '/b',
'/c': '/d',
'/fi/a': '/b',
'/fi/c': '/d',
},
expectedWarnings: [],
},
{
encodedRedirects: '{"/a": "/b", "/c": "/d"}',
language: 'en',
expectedOutput: {
'/a': '/b',
'/c': '/d',
'/en/a': '/b',
'/en/c': '/d',
},
expectedWarnings: [],
},
{
encodedRedirects: '[{"/a/b/c": "/d"}, {"/e/f/": "/g/h/"}]',
language: 'sv',
expectedOutput: {
'/a/b/c': '/d',
'/e/f/': '/g/h/',
'/sv/a/b/c': '/d',
'/sv/e/f/': '/g/h/',
},
expectedWarnings: [],
},
])(
'parseRedirects($encodedRedirects, "$language") == $expectedOutput with expected warnings',
({ encodedRedirects, language, expectedOutput, expectedWarnings }) => {
expect(parseRedirects(encodedRedirects, language)).toStrictEqual(
expectedOutput
);
for (const warning of expectedWarnings) {
expect(consoleWarningSpy).toHaveBeenCalledWith(warning);
}
expect(consoleWarningSpy).toHaveBeenCalledTimes(expectedWarnings.length);
}
);
});
3 changes: 3 additions & 0 deletions apps/sports-helsinki/src/edge-runtime-compatible/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// TODO: For some reason middleware cannot read `'@events-helsinki/components` package without breaking the build
export const LOCALES = ['fi', 'en', 'sv'] as const; // i18n.locales.join('|');
export const DEFAULT_LANGUAGE = 'fi';
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Check if the given pathname is a valid redirect address
* @param pathname The pathname to check
* @returns True if the pathname is a valid redirect address, otherwise false
*/
export function isValidRedirectAddress(pathname: unknown): boolean {
/**
* Unicode character class escapes (e.g. \p{Letter}):
*
* L = Letter, includes categories:
* - Lu = Uppercase Letter = https://www.compart.com/en/unicode/category/Lu for e.g. "A" (Latin capital letter A)
* - Ll = Lowercase Letter = https://www.compart.com/en/unicode/category/Ll for e.g. "a" (Latin small letter A)
* - Lt = Titlecase Letter = https://www.compart.com/en/unicode/category/Lt for e.g. "Lj" (Latin capital letter lj)
* - Lm = Modifier Letter = https://www.compart.com/en/unicode/category/Lm for e.g. "ʼ" (Modifier Letter Apostrophe)
* - Lo = Other Letter = https://www.compart.com/en/unicode/category/Lo for e.g. "ƒ" (Latin small letter f with hook)
*
* N = Number, includes categories:
* - Nd = Decimal Number = https://www.compart.com/en/unicode/category/Nd for e.g. "5" (Digit Five)
* - Nl = Letter Number = https://www.compart.com/en/unicode/category/Nl for e.g. "Ⅷ" (Roman Numeral Eight)
* - No = Other Number = https://www.compart.com/en/unicode/category/No for e.g. "½" (Vulgar Fraction One Half)
*
* Pc = Connector Punctuation = https://www.compart.com/en/unicode/category/Pc for e.g. "_" (underscore)
* Pd = Dash Punctuation = https://www.compart.com/en/unicode/category/Pd for e.g. "-" (hyphen-minus)
* Sc = Currency Symbol = https://www.compart.com/en/unicode/category/Sc for e.g. "$" (Dollar sign)
*
* See documentation:
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Unicode_character_class_escape
* https://unicode.org/reports/tr18/#General_Category_Property
*/
return (
typeof pathname === 'string' &&
(pathname == '/' ||
/^(?:\/[\p{L}\p{N}\p{Pc}\p{Pd}\p{Sc}#%&+,.:;=?@]+)+\/?$/u.test(pathname))
);
}
Loading

0 comments on commit 6170274

Please sign in to comment.