Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { parseJsonPath } from './jsonPathParser'

describe('parseJsonPath', () => {
it('should parse variable names with dot notation', () => {
expect(parseJsonPath('a')).toEqual(['a'])
expect(parseJsonPath('foo.bar')).toEqual(['foo', 'bar'])
expect(parseJsonPath('foo.bar.qux')).toEqual(['foo', 'bar', 'qux'])
})

it('should parse property names with bracket notation', () => {
expect(parseJsonPath("['a']")).toEqual(['a'])
expect(parseJsonPath('["a"]')).toEqual(['a'])
expect(parseJsonPath('[\'foo\']["bar"]')).toEqual(['foo', 'bar'])
expect(parseJsonPath("['foo']['bar']['qux']")).toEqual(['foo', 'bar', 'qux'])
})

it('should parse variable and property names mixed', () => {
expect(parseJsonPath("['foo'].bar['qux']")).toEqual(['foo', 'bar', 'qux'])
})

it('should parse array indexes', () => {
expect(parseJsonPath('[0]')).toEqual(['0'])
expect(parseJsonPath('foo[12]')).toEqual(['foo', '12'])
expect(parseJsonPath("['foo'][12]")).toEqual(['foo', '12'])
})

it('should parse property names with unsupported variable name characters', () => {
expect(parseJsonPath("['foo\\n']")).toEqual(['foo\\n'])
expect(parseJsonPath("['foo\\'']")).toEqual(["foo\\'"])
expect(parseJsonPath('["foo\\""]')).toEqual(['foo\\"'])
expect(parseJsonPath("['foo[]']")).toEqual(['foo[]'])
})

it('should return an empty array for an invalid path', () => {
expect(parseJsonPath('.foo')).toEqual([])
expect(parseJsonPath('.')).toEqual([])
expect(parseJsonPath('foo.')).toEqual([])
expect(parseJsonPath('foo..bar')).toEqual([])
expect(parseJsonPath("[['foo']")).toEqual([])
expect(parseJsonPath("['foo'")).toEqual([])
expect(parseJsonPath("['foo]")).toEqual([])
expect(parseJsonPath('[1')).toEqual([])
expect(parseJsonPath('foo]')).toEqual([])
expect(parseJsonPath("[foo']")).toEqual([])
expect(parseJsonPath("['foo''bar']")).toEqual([])
expect(parseJsonPath("['foo\\o']")).toEqual([])
expect(parseJsonPath("['foo']a")).toEqual([])
expect(parseJsonPath('["foo\']')).toEqual([])
})
})
148 changes: 148 additions & 0 deletions packages/rum-core/src/domain/configuration/jsonPathParser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/**
* Extract path parts from a simple JSON path expression, return [] for an invalid path
*
* Supports:
* - Dot notation: `foo.bar.baz`
* - Bracket notation: `['foo']["bar"]`
* - Array indices: `items[0]`, `data['users'][1]`
*
* Examples:
* parseJsonPath("['foo'].bar[12]")
* => ['foo', 'bar', '12']
*
* parseJsonPath("['foo")
* => []
*
*
* Useful references:
* - https://goessner.net/articles/JsonPath/
* - https://jsonpath.com/
* - https://github.com/jsonpath-standard
*/
export function parseJsonPath(path: string): string[] {
const pathParts: string[] = []
let previousToken = Token.START
let currentToken: Token | undefined
let quoteContext: string | undefined
let currentPathPart = ''
for (const char of path) {
// find which kind of token is this char
currentToken = findInSet(ALLOWED_NEXT_TOKENS[previousToken], (token) => TOKEN_PREDICATE[token](char, quoteContext))
if (!currentToken) {
return []
}
if (ALLOWED_PATH_PART_TOKENS.has(currentToken)) {
// buffer the char if it belongs to the path part
// ex: foo['bar']
// ^ ^
currentPathPart += char
} else if (ALLOWED_PATH_PART_DELIMITER_TOKENS.has(currentToken) && currentPathPart !== '') {
// close the current path part if we have reach a path part delimiter
// ex: foo.bar['qux']
// ^ ^ ^
pathParts.push(currentPathPart)
currentPathPart = ''
} else if (currentToken === Token.QUOTE_START) {
quoteContext = char
} else if (currentToken === Token.QUOTE_END) {
quoteContext = undefined
}
previousToken = currentToken
}
if (!ALLOWED_NEXT_TOKENS[previousToken].has(Token.END)) {
return []
}
if (currentPathPart !== '') {
pathParts.push(currentPathPart)
}
return pathParts
}

const enum Token {
START,
END,

VARIABLE_FIRST_LETTER,
VARIABLE_LETTER,
DOT,

BRACKET_START,
BRACKET_END,
NUMBER_LETTER,

QUOTE_START,
QUOTE_END,
QUOTE_PROPERTY_LETTER,
QUOTE_ESCAPE,
QUOTE_ESCAPABLE_LETTER,
}

const VARIABLE_FIRST_LETTER = /[a-zA-Z_$]/
const VARIABLE_LETTER = /[a-zA-Z0-9_$]/
const NUMBER_CHAR = /[0-9]/
const QUOTE_ESCAPABLE_LETTERS = '/\\bfnrtu'
const QUOTE_CHAR = '\'"'

const TOKEN_PREDICATE: { [token in Token]: (char: string, quoteContext?: string) => boolean } = {
// no char should match to START or END
[Token.START]: (_: string) => false,
[Token.END]: (_: string) => false,

[Token.VARIABLE_FIRST_LETTER]: (char: string) => VARIABLE_FIRST_LETTER.test(char),
[Token.VARIABLE_LETTER]: (char: string) => VARIABLE_LETTER.test(char),
[Token.DOT]: (char: string) => char === '.',

[Token.BRACKET_START]: (char: string) => char === '[',
[Token.BRACKET_END]: (char: string) => char === ']',
[Token.NUMBER_LETTER]: (char: string) => NUMBER_CHAR.test(char),

[Token.QUOTE_START]: (char: string) => QUOTE_CHAR.includes(char),
[Token.QUOTE_END]: (char: string, quoteContext?: string) => char === quoteContext,
[Token.QUOTE_PROPERTY_LETTER]: (_: string) => true, // any char can be used in property
[Token.QUOTE_ESCAPE]: (char: string) => char === '\\',
[Token.QUOTE_ESCAPABLE_LETTER]: (char: string, quoteContext?: string) =>
`${quoteContext}${QUOTE_ESCAPABLE_LETTERS}`.includes(char),
}

const ALLOWED_NEXT_TOKENS: { [token in Token]: Set<Token> } = {
[Token.START]: new Set([Token.VARIABLE_FIRST_LETTER, Token.BRACKET_START]),
[Token.END]: new Set([]),

[Token.VARIABLE_FIRST_LETTER]: new Set([Token.VARIABLE_LETTER, Token.DOT, Token.BRACKET_START, Token.END]),
[Token.VARIABLE_LETTER]: new Set([Token.VARIABLE_LETTER, Token.DOT, Token.BRACKET_START, Token.END]),
[Token.DOT]: new Set([Token.VARIABLE_FIRST_LETTER]),

[Token.BRACKET_START]: new Set([Token.QUOTE_START, Token.NUMBER_LETTER]),
[Token.BRACKET_END]: new Set([Token.DOT, Token.BRACKET_START, Token.END]),
[Token.NUMBER_LETTER]: new Set([Token.NUMBER_LETTER, Token.BRACKET_END]),

[Token.QUOTE_START]: new Set([Token.QUOTE_ESCAPE, Token.QUOTE_END, Token.QUOTE_PROPERTY_LETTER]),
[Token.QUOTE_END]: new Set([Token.BRACKET_END]),
[Token.QUOTE_PROPERTY_LETTER]: new Set([Token.QUOTE_ESCAPE, Token.QUOTE_END, Token.QUOTE_PROPERTY_LETTER]),
[Token.QUOTE_ESCAPE]: new Set([Token.QUOTE_ESCAPABLE_LETTER]),
[Token.QUOTE_ESCAPABLE_LETTER]: new Set([Token.QUOTE_ESCAPE, Token.QUOTE_END, Token.QUOTE_PROPERTY_LETTER]),
}

// foo['bar\n'][12]
// ^^ ^ ^^ ^
const ALLOWED_PATH_PART_TOKENS = new Set([
Token.VARIABLE_FIRST_LETTER,
Token.VARIABLE_LETTER,
Token.NUMBER_LETTER,

Token.QUOTE_PROPERTY_LETTER,
Token.QUOTE_ESCAPE,
Token.QUOTE_ESCAPABLE_LETTER,
])

// foo.bar['qux']
// ^ ^ ^
const ALLOWED_PATH_PART_DELIMITER_TOKENS = new Set([Token.DOT, Token.BRACKET_START, Token.BRACKET_END])

function findInSet<T>(set: Set<T>, predicate: (item: T) => boolean): T | undefined {
for (const item of set) {
if (predicate(item)) {
return item
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,163 @@ describe('remoteConfiguration', () => {
})
})

describe('js strategy', () => {
const root = window as any

it('should resolve a value from a variable content', () => {
root.foo = 'bar'
registerCleanupTask(() => {
delete root.foo
})
expectAppliedRemoteConfigurationToBe(
{
version: { rcSerializedType: 'dynamic', strategy: 'js', path: 'foo' },
},
{ version: 'bar' }
)
})

it('should resolve a value from an object property', () => {
root.foo = { bar: { qux: '123' } }
registerCleanupTask(() => {
delete root.foo
})
expectAppliedRemoteConfigurationToBe(
{
version: { rcSerializedType: 'dynamic', strategy: 'js', path: 'foo.bar.qux' },
},
{ version: '123' }
)
})

it('should resolve a string value with an extractor', () => {
root.foo = 'version-123'
registerCleanupTask(() => {
delete root.foo
})
expectAppliedRemoteConfigurationToBe(
{
version: {
rcSerializedType: 'dynamic',
strategy: 'js',
path: 'foo',
extractor: { rcSerializedType: 'regex', value: '\\d+' },
},
},
{ version: '123' }
)
})

it('should resolve to a non string value', () => {
root.foo = 23
registerCleanupTask(() => {
delete root.foo
})
expectAppliedRemoteConfigurationToBe(
{
version: { rcSerializedType: 'dynamic', strategy: 'js', path: 'foo' },
},
{ version: 23 as any }
)
})

it('should not apply the extractor to a non string value', () => {
root.foo = 23
registerCleanupTask(() => {
delete root.foo
})
expectAppliedRemoteConfigurationToBe(
{
version: {
rcSerializedType: 'dynamic',
strategy: 'js',
path: 'foo',
extractor: { rcSerializedType: 'regex', value: '\\d+' },
},
},
{ version: 23 as any }
)
})

it('should resolve a value from an array item', () => {
root.foo = { bar: [{ qux: '123' }] }
registerCleanupTask(() => {
delete root.foo
})
expectAppliedRemoteConfigurationToBe(
{
version: { rcSerializedType: 'dynamic', strategy: 'js', path: 'foo.bar[0].qux' },
},
{ version: '123' }
)
})

it('should resolve to undefined and display an error if the JSON path is invalid', () => {
expectAppliedRemoteConfigurationToBe(
{
version: { rcSerializedType: 'dynamic', strategy: 'js', path: '.' },
},
{ version: undefined }
)
expect(displaySpy).toHaveBeenCalledWith("Invalid JSON path in the remote configuration: '.'")
})

it('should resolve to undefined and display an error if the variable access throws', () => {
root.foo = {
get bar() {
throw new Error('foo')
},
}
registerCleanupTask(() => {
delete root.foo
})
expectAppliedRemoteConfigurationToBe(
{
version: { rcSerializedType: 'dynamic', strategy: 'js', path: 'foo.bar' },
},
{ version: undefined }
)
expect(displaySpy).toHaveBeenCalledWith("Error accessing: 'foo.bar'", new Error('foo'))
})

it('should resolve to undefined if the variable does not exist', () => {
expectAppliedRemoteConfigurationToBe(
{
version: { rcSerializedType: 'dynamic', strategy: 'js', path: 'missing' },
},
{ version: undefined }
)
})

it('should resolve to undefined if the property does not exist', () => {
root.foo = {}
registerCleanupTask(() => {
delete root.foo
})

expectAppliedRemoteConfigurationToBe(
{
version: { rcSerializedType: 'dynamic', strategy: 'js', path: 'foo.missing' },
},
{ version: undefined }
)
})

it('should resolve to undefined if the array index does not exist', () => {
root.foo = []
registerCleanupTask(() => {
delete root.foo
})

expectAppliedRemoteConfigurationToBe(
{
version: { rcSerializedType: 'dynamic', strategy: 'js', path: 'foo[0]' },
},
{ version: undefined }
)
})
})

describe('with extractor', () => {
const COOKIE_NAME = 'unit_rc'

Expand Down
Loading