@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@financial-times/dotcom-server-app-context",
3
- "version": "7.3.1",
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
@@ -0,0 +1,2 @@
1
+ export * from './AppContext'
2
+ export type { TAppContext } from './types'
@@ -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
+ }
@@ -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
+ }