@financial-times/dotcom-server-app-context 7.3.1 → 7.3.2
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 +3 -2
- package/src/AppContext.ts +33 -0
- package/src/__test__/AppContext.spec.ts +75 -0
- package/src/__test__/__fixtures__/contextData.ts +18 -0
- package/src/__test__/filterEmptyData.spec.ts +32 -0
- package/src/__test__/validate.spec.ts +20 -0
- package/src/filterEmptyData.ts +19 -0
- package/src/index.ts +2 -0
- package/src/schema.json +88 -0
- package/src/types.d.ts +15 -0
- package/src/validate.ts +24 -0
package/package.json
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
{
|
2
2
|
"name": "@financial-times/dotcom-server-app-context",
|
3
|
-
"version": "7.3.
|
3
|
+
"version": "7.3.2",
|
4
4
|
"description": "",
|
5
5
|
"main": "dist/node/index.js",
|
6
6
|
"types": "src/index.ts",
|
@@ -30,7 +30,8 @@
|
|
30
30
|
"npm": "7.x || 8.x"
|
31
31
|
},
|
32
32
|
"files": [
|
33
|
-
"dist/"
|
33
|
+
"dist/",
|
34
|
+
"src/"
|
34
35
|
],
|
35
36
|
"repository": {
|
36
37
|
"type": "git",
|
@@ -0,0 +1,33 @@
|
|
1
|
+
import { TAppContext } from './types'
|
2
|
+
import validate from './validate'
|
3
|
+
import filterEmptyData from './filterEmptyData'
|
4
|
+
|
5
|
+
export type TAppContextOptions = {
|
6
|
+
appContext?: Partial<TAppContext>
|
7
|
+
}
|
8
|
+
|
9
|
+
export class AppContext {
|
10
|
+
private data: Partial<TAppContext> = {}
|
11
|
+
|
12
|
+
constructor(options: TAppContextOptions = {}) {
|
13
|
+
const data = filterEmptyData({ ...options.appContext })
|
14
|
+
|
15
|
+
for (const [property, value] of Object.entries(data)) {
|
16
|
+
this.set(property, value)
|
17
|
+
}
|
18
|
+
}
|
19
|
+
|
20
|
+
get(property: string) {
|
21
|
+
return this.data[property]
|
22
|
+
}
|
23
|
+
|
24
|
+
set(property: string, value: any) {
|
25
|
+
if (validate(property, value)) {
|
26
|
+
this.data[property] = value
|
27
|
+
}
|
28
|
+
}
|
29
|
+
|
30
|
+
getAll(): Partial<TAppContext> {
|
31
|
+
return Object.freeze({ ...this.data })
|
32
|
+
}
|
33
|
+
}
|
@@ -0,0 +1,75 @@
|
|
1
|
+
import { AppContext } from '../AppContext'
|
2
|
+
import * as fixtures from './__fixtures__/contextData'
|
3
|
+
|
4
|
+
describe('dotcom-server-app-context/src/AppContext', () => {
|
5
|
+
describe('constructor', () => {
|
6
|
+
let instance
|
7
|
+
|
8
|
+
beforeAll(() => {
|
9
|
+
instance = new AppContext({ appContext: fixtures.validAppContext })
|
10
|
+
})
|
11
|
+
|
12
|
+
it('sets the given app context data', () => {
|
13
|
+
expect(instance.data).toEqual(fixtures.validAppContext)
|
14
|
+
})
|
15
|
+
|
16
|
+
describe('invalid data', () => {
|
17
|
+
it('throws if any app context data is invalid', () => {
|
18
|
+
const init = () =>
|
19
|
+
new AppContext({
|
20
|
+
appContext: fixtures.invalidAppContext as any
|
21
|
+
})
|
22
|
+
|
23
|
+
expect(init).toThrow()
|
24
|
+
})
|
25
|
+
})
|
26
|
+
})
|
27
|
+
|
28
|
+
describe('.get()', () => {
|
29
|
+
let instance
|
30
|
+
|
31
|
+
beforeEach(() => {
|
32
|
+
instance = new AppContext({ appContext: fixtures.validAppContext })
|
33
|
+
})
|
34
|
+
|
35
|
+
it('returns the value of the requested app context property', () => {
|
36
|
+
const result = instance.get('appVersion')
|
37
|
+
expect(result).toBe(fixtures.validAppContext.appVersion)
|
38
|
+
})
|
39
|
+
})
|
40
|
+
|
41
|
+
describe('.set()', () => {
|
42
|
+
let instance
|
43
|
+
|
44
|
+
beforeEach(() => {
|
45
|
+
instance = new AppContext()
|
46
|
+
})
|
47
|
+
|
48
|
+
it('sets the value of the specified property', () => {
|
49
|
+
instance.set('appVersion', 'v12')
|
50
|
+
expect(instance.data.appVersion).toBe('v12')
|
51
|
+
})
|
52
|
+
|
53
|
+
it('throws if the given value is invalid', () => {
|
54
|
+
expect(() => instance.set('conceptId', 123)).toThrow()
|
55
|
+
})
|
56
|
+
})
|
57
|
+
|
58
|
+
describe('.getAll()', () => {
|
59
|
+
let instance
|
60
|
+
|
61
|
+
beforeEach(() => {
|
62
|
+
instance = new AppContext({ appContext: fixtures.validAppContext })
|
63
|
+
})
|
64
|
+
|
65
|
+
it('returns a clone of the app context data', () => {
|
66
|
+
const result = instance.getAll()
|
67
|
+
expect(result).not.toBe(instance.data)
|
68
|
+
})
|
69
|
+
|
70
|
+
it('freezes the app context data clone', () => {
|
71
|
+
const result = instance.getAll()
|
72
|
+
expect(Object.isFrozen(result)).toBe(true)
|
73
|
+
})
|
74
|
+
})
|
75
|
+
})
|
@@ -0,0 +1,18 @@
|
|
1
|
+
export const validAppContext = Object.freeze({
|
2
|
+
appName: 'article',
|
3
|
+
appVersion: '882797258625531f20d604f6441ef8cfcb2d772b',
|
4
|
+
edition: 'uk',
|
5
|
+
product: 'next',
|
6
|
+
abTestState: 'subscriberCohort:on,premiumCohort:on,topicTracker_UIDemo:100-percent',
|
7
|
+
contentId: 'c5935758-7730-11e9-bbad-7c18c0ea0201',
|
8
|
+
contentType: 'article',
|
9
|
+
conceptId: 'c5935738-7730-11e9-bbad-7c18c0ea8201',
|
10
|
+
conceptType: 'http://www.ft.com/ontology/Location',
|
11
|
+
isProduction: true,
|
12
|
+
publishReference: 'tid_17wmwszvk3'
|
13
|
+
})
|
14
|
+
|
15
|
+
export const invalidAppContext = {
|
16
|
+
...validAppContext,
|
17
|
+
isProduction: 'yes'
|
18
|
+
}
|
@@ -0,0 +1,32 @@
|
|
1
|
+
import subject from '../filterEmptyData'
|
2
|
+
|
3
|
+
const fixture = Object.freeze({
|
4
|
+
appName: 'article',
|
5
|
+
abTest: ' ',
|
6
|
+
appVersion: null,
|
7
|
+
isProduction: false
|
8
|
+
})
|
9
|
+
|
10
|
+
describe('dotcom-server-app-context/src/filterEmptyData', () => {
|
11
|
+
it('returns a new object', () => {
|
12
|
+
const result = subject(fixture)
|
13
|
+
expect(result).not.toBe(fixture)
|
14
|
+
})
|
15
|
+
|
16
|
+
it('removes null values', () => {
|
17
|
+
const result = subject(fixture)
|
18
|
+
expect(result).not.toHaveProperty('appVersion')
|
19
|
+
})
|
20
|
+
|
21
|
+
it('removes empty string values', () => {
|
22
|
+
const result = subject(fixture)
|
23
|
+
expect(result).not.toHaveProperty('abTest')
|
24
|
+
})
|
25
|
+
|
26
|
+
it('copies all defined values', () => {
|
27
|
+
const result = subject(fixture)
|
28
|
+
|
29
|
+
expect(result).toHaveProperty('appName', 'article')
|
30
|
+
expect(result).toHaveProperty('isProduction', false)
|
31
|
+
})
|
32
|
+
})
|
@@ -0,0 +1,20 @@
|
|
1
|
+
import subject from '../validate'
|
2
|
+
|
3
|
+
describe('dotcom-server-app-context/src/validate', () => {
|
4
|
+
it('returns true when given a valid property/value', () => {
|
5
|
+
expect(subject('isProduction', true)).toBe(true)
|
6
|
+
})
|
7
|
+
|
8
|
+
it('throws an error when given an invalid property/value', () => {
|
9
|
+
expect(() => subject('isProduction', 'yes')).toThrow(
|
10
|
+
'Validation error: data.isProduction should be boolean'
|
11
|
+
)
|
12
|
+
})
|
13
|
+
|
14
|
+
it('throws an error when given an unknown property/value', () => {
|
15
|
+
expect(() => subject('thisProperty', 'isNotInTheSchema')).toThrow(
|
16
|
+
'Validation error: data should NOT have additional properties, received "isNotInTheSchema"' +
|
17
|
+
'\nIf you want to share application specific data with the client, consider using @financial-times/dotcom-ui-data-embed.'
|
18
|
+
)
|
19
|
+
})
|
20
|
+
})
|
@@ -0,0 +1,19 @@
|
|
1
|
+
const isEmptyString = (value) => typeof value === 'string' && value.trim().length === 0
|
2
|
+
|
3
|
+
const isDefined = (value) => value !== undefined && value !== null
|
4
|
+
|
5
|
+
type AnyObject = {
|
6
|
+
[key: string]: any
|
7
|
+
}
|
8
|
+
|
9
|
+
export default function filterEmptyProperties(properties: AnyObject): AnyObject {
|
10
|
+
const result = {}
|
11
|
+
|
12
|
+
for (const [key, value] of Object.entries(properties)) {
|
13
|
+
if (isDefined(value) && !isEmptyString(value)) {
|
14
|
+
result[key] = value
|
15
|
+
}
|
16
|
+
}
|
17
|
+
|
18
|
+
return result
|
19
|
+
}
|
package/src/index.ts
ADDED
package/src/schema.json
ADDED
@@ -0,0 +1,88 @@
|
|
1
|
+
{
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
3
|
+
"type": "object",
|
4
|
+
"title": "FT App Context Schema",
|
5
|
+
"properties": {
|
6
|
+
"abTestState": {
|
7
|
+
"type": "string",
|
8
|
+
"description": "The A/B test flags data as a comma delimited string",
|
9
|
+
"examples": ["subscriberCohort:on,premiumCohort:on,nonUSACohort:on"],
|
10
|
+
"pattern": "^,*([0-9A-Za-z-_]+:[0-9A-Za-z-_]+,*)+$"
|
11
|
+
},
|
12
|
+
"appName": {
|
13
|
+
"type": "string",
|
14
|
+
"description": "The name of the application",
|
15
|
+
"examples": ["front-page", "stream-page", "article-page"],
|
16
|
+
"pattern": "^.+$"
|
17
|
+
},
|
18
|
+
"appVersion": {
|
19
|
+
"type": "string",
|
20
|
+
"description": "The running version of the app (usually a Git commit hash)",
|
21
|
+
"examples": ["882797258625531f20d604f6441ef8cfcb2d772b"],
|
22
|
+
"pattern": "^.+$"
|
23
|
+
},
|
24
|
+
"conceptId": {
|
25
|
+
"type": "string",
|
26
|
+
"description": "The UUID of the concept on the current page",
|
27
|
+
"examples": ["c5935758-7730-11e9-bbad-7c18c0ea0201"],
|
28
|
+
"pattern": "^[0-9a-z]{8}-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{12}$"
|
29
|
+
},
|
30
|
+
"conceptType": {
|
31
|
+
"type": "string",
|
32
|
+
"description": "The type of concept on the current page",
|
33
|
+
"examples": ["http://www.ft.com/ontology/product/Brand", "http://www.ft.com/ontology/Location"],
|
34
|
+
"pattern": "^http://www.ft.com/ontology/.+$"
|
35
|
+
},
|
36
|
+
"contentId": {
|
37
|
+
"type": "string",
|
38
|
+
"description": "The UUID of the content on the current page",
|
39
|
+
"examples": ["c5935758-7730-11e9-bbad-7c18c0ea0201"],
|
40
|
+
"pattern": "^[0-9a-z]{8}-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{12}$"
|
41
|
+
},
|
42
|
+
"contentType": {
|
43
|
+
"type": "string",
|
44
|
+
"description": "The type or sub-type of the content on the current page",
|
45
|
+
"examples": ["article", "video", "audio", "podcast", "package", "live-blog"],
|
46
|
+
"pattern": "^(article|video|audio|podcast|package|live-blog)$"
|
47
|
+
},
|
48
|
+
"edition": {
|
49
|
+
"type": "string",
|
50
|
+
"description": "The selected FT edition",
|
51
|
+
"examples": ["uk", "international"],
|
52
|
+
"pattern": "^(uk|international)$"
|
53
|
+
},
|
54
|
+
"isProduction": {
|
55
|
+
"type": "boolean",
|
56
|
+
"description": "If the app is currently running in a production environment",
|
57
|
+
"default": false
|
58
|
+
},
|
59
|
+
"isUserLoggedIn": {
|
60
|
+
"type": "boolean",
|
61
|
+
"description": "If the visitor is signed in to an FT account",
|
62
|
+
"default": false
|
63
|
+
},
|
64
|
+
"product": {
|
65
|
+
"type": "string",
|
66
|
+
"description": "The product name",
|
67
|
+
"default": "next",
|
68
|
+
"pattern": "^.+$"
|
69
|
+
},
|
70
|
+
"publishReference": {
|
71
|
+
"type": "string",
|
72
|
+
"description": "The publish reference of the content on the current page",
|
73
|
+
"examples": [
|
74
|
+
"tid_17wmwszvk3",
|
75
|
+
"SYNTHETIC-REQ-MONtid_UrnYVM6Waz_carousel_1534570047",
|
76
|
+
"republish_-453878e5-94e5-4e52-bfba-b16b024f31f8_carousel_1577700591"
|
77
|
+
],
|
78
|
+
"pattern": "^.+$"
|
79
|
+
},
|
80
|
+
"pageKitVersion": {
|
81
|
+
"type": "string",
|
82
|
+
"description": "The version of Page Kit powering the app",
|
83
|
+
"examples": ["1.0.0"],
|
84
|
+
"pattern": "^.+$"
|
85
|
+
}
|
86
|
+
},
|
87
|
+
"additionalProperties": false
|
88
|
+
}
|
package/src/types.d.ts
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
export interface TAppContext {
|
2
|
+
appName: string
|
3
|
+
appVersion: string
|
4
|
+
product: string
|
5
|
+
edition: string
|
6
|
+
abTestState: string
|
7
|
+
contentId?: string
|
8
|
+
contentType?: string
|
9
|
+
conceptId?: string
|
10
|
+
conceptType?: string
|
11
|
+
isProduction: boolean
|
12
|
+
isUserLoggedIn?: boolean
|
13
|
+
publishReference?: string
|
14
|
+
[key: string]: any
|
15
|
+
}
|
package/src/validate.ts
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
import Ajv from 'ajv'
|
2
|
+
import schema from './schema.json'
|
3
|
+
|
4
|
+
const ajv = new Ajv()
|
5
|
+
|
6
|
+
const isValid = ajv.compile(schema)
|
7
|
+
|
8
|
+
export default function validate(field: string, value): boolean {
|
9
|
+
const data = { [field]: value }
|
10
|
+
|
11
|
+
if (isValid(data)) {
|
12
|
+
return true
|
13
|
+
} else {
|
14
|
+
let errorMessage = `Validation error: ${ajv.errorsText(isValid.errors)}, received "${value}"`
|
15
|
+
const hasErrorsForAdditionProperties = isValid.errors.some(
|
16
|
+
(error) => error.keyword === 'additionalProperties'
|
17
|
+
)
|
18
|
+
if (hasErrorsForAdditionProperties) {
|
19
|
+
errorMessage +=
|
20
|
+
'\nIf you want to share application specific data with the client, consider using @financial-times/dotcom-ui-data-embed.'
|
21
|
+
}
|
22
|
+
throw Error(errorMessage)
|
23
|
+
}
|
24
|
+
}
|