Path
@finnair/path
contains partly JsonPath
compatible path utilities:
Path
- concrete JSON paths used to locate, read or write a of an object.PathMatcher
- a JsonPath like query processor.Projection
- PathMatcher based include/exclude mapper for providing partial results from e.g. an API.
Parsers for Path and PathMatcher are available in a separate package @finnair/path-parser
.
Getting Started
Install v-validation using yarn
:
yarn add @finnair/path
Or npm
:
npm install @finnair/path
Use Case Examples
Report Validation Errors
Path can be used to point a location of invalid value (see v-validation
. Path's immutability and fluent API makes it easy and safe to use.
Analyze Changes
Path can be used to report changes made to an object:
const originalValue = fetchResource();
const updatedValue = await doUpdate(originalValue);
const changes: Map<Path, any> = analyzeChanges(updatedValue, originalValue);
Detect Interesting Changes
In message based systems, consumers may be interested only in specific changes. Once a change is analyzed, PathMatcher
can be used to check if it's of interest to a particular consumer.
const subscription = [PathMatcher.of('interestingProperty'), PathMatcher.of('interestingArray', AnyIndex, 'someProperty')];
const isInteresting = Array.from(changes.keys()).some(path => subscription.some(pathMatcher => pathMatcher.prefixMatch(path)));
Apply Changes (Patch)
Changes can also be applied to another (newer) version of the same object safely. This is important in systems where there
const latestValue = fetchAndLockResource();
changes.forEach((newValue, path) => {
path.set(latestValue, newValue);
});
updateResource(latestValue);
NOTE: Updating an array with Path.set
will truncate possible undefined values from the end of the array. This allows
removing trailing elements without leaving undefined elements in place.
Include/Exclude Projection
While GraphQL is all about projections, something similar can also be implemented in a REST API with include/exclude parameters. Projection
and parsePathMatcher
function provides means to process results safely based on such a user input. This is, of course, very simplified projection compared to what GraphQL has to offer, but it's also... well, simpler.
Projection can also be used to optimize fetching expensive relations as it also supports matching Paths and not just mapping actual values:
const resource = fetchResult(request);
const projection = Projection.of(parseIncludes(request), parseExcludes(request));
const result = {
...resource,
veryExpensiveRelation: projection.match(Path.of('veryExpensiveRelation')) ? fetchVeryExpensiveRelation(resource) : undefined,
};
return projection.map(result);
Using Path
Path
is an immutable representation of a concrete JsonPath, consisting of strings (properties) and numbers (indexes).
import { Path } from '@finnair/path';
Path.of();
Path.of('array', 1, 'property');
Path.of().property('array').index(0);
Path.of('parent').concat(Path.of('child', 'name'));
Path.of('child', 'name').connectTo(Path.of('parent'));
Array.from(Path.of(1, 2, 3));
Path.of('parent', 'child').length;
Path.of('array', 3).componentAt(0);
Path.of('array', 3).componentAt(1);
Path.of('array', 1).get({ array: [1, 2, 3] });
Path.of('array', 1).set({}, 'foo');
Path.of('parent', 'child', 'name').set({}, 'child name');
Path.of('array', 1).unset({ array: [1, 2, 3] });
Path.of('array', 1).unset({});
Path.of('array', 2).set({ array: [1, undefined, 3] }, undefined);
Path.of('array', 0, 'property with spaces').toJSON();
Parsing Paths
import { parsePath } from '@finnair/path-parser';
parsePath(`$.array[1]["\\"property\\" with spaces and 'quotes'"]`);
parsePath(`$['single quotes']`);
parsePath(`$['single\'quote']`);
parsePath(`$['\\u0027']`);
Using PathMatcher
PathMatcher
is constructed from PathExpression[]
. Each PathExpression
is capable of handling one path component, find matching values, testing if a (concrete) Path component is a match and serialize the expression to string. As PathExpression
is just a simple interface, it is possible also to implement custom PathExpressions
, however, the default parser cannot handle them of course.
Constructing PathMatcher
import { PathMatcher, AnyIndex, AnyProperty, UnionMatcher } from '@finnair/path';
PathMatcher.of('array', AnyIndex, 'name');
PathMatcher.of(AnyProperty, 'length');
PathMatcher.of('child', UnionMatcher.of('name', 'value'));
Finding values
find
returns Nodes of path and value
PathMatcher.of('array', AnyIndex).find({ array: [1, 2], other: 'property' });
findValues
returns actual values
let array: any = [1, 2];
array.property = 'stupid thing to do';
PathMatcher.of(AnyProperty).findValues(array));
PathMatcher.of(AnyIndex).findValues(array);
array = [];
array[2] = 'first actual value';
PathMatcher.of(AnyIndex).findValues(array);
Finding first match and value is also directly supported
PathMatcher.of(AnyIndex).findFirst([undefined, 2, 3]);
PathMatcher.of(AnyIndex).findFirst([undefined, 2, 3]);
PathMatcher.of(4).findFirst([]);
Matching Paths
Sometimes it's usefull to be able to also match Paths directly against a PathMatcher...
PathMatcher.of(AnyProperty).match(Path.of('parent'));
PathMatcher.of(AnyProperty).match(Path.of('parent', 'child'));
PathMatcher.of(AnyProperty).match(Path.of());
PathMatcher.of(AnyProperty).prefixMatch(Path.of('parent'));
PathMatcher.of(AnyProperty).prefixMatch(Path.of('parent', child));
PathMatcher.of(AnyProperty).prefixMatch(Path.of());
PathMatcher.of(AnyProperty).partialMatch(Path.of('parent'));
PathMatcher.of(AnyProperty).partialMatch(Path.of('parent', child));
PathMatcher.of(AnyProperty).partialMatch(Path.of());
PathMatcher.of('parent', 'one').partialMatch(Path.of('parent', 'two'));
toJSON
PathMatcher.toJSON()
returns JsonPath
like representation of the matcher. Main difference is that bracket–notation (when required) uses JSON string encoding.
Parsing PathMatchers
parsePathMatcher
parses simple JsonPath like expressions. Supported expressions are
Expression | Description |
---|
$.property | Identifiers matching RegExp /^[a-zA-Z_][a-zA-Z0-9_]*$/ |
$[0] | Index match |
$.* | Any property matcher, wildcard (matches also array indexes) |
$[*] | Any index matcher, wildcard (matches only array indexes) |
$["JSON string encoded property"] | Property as JSON encoded string |
$['JSON string encoded property'] | Property as single quoted, but otherwise JSON encoded, string(*) |
$[union,"of",4,'components'] | Union matcher that also supports identifiers and JSON encoded strings |
*) This is the official way of JsonPath
, but the specification is a bit unclear on the encoding. In this library we prefer proper JSON string encoding with double quotes.
import { parsePathMatcher } from '@finnair/path-parser';
parsePathMatcher(`$.array[0][*].*['union',"of",properties,1]`);
Using Projection
Projection
is a collection of include and exclude PathMatchers
. It's main use is to map a projection of it's input based on the include/exclude configuration. It also allows matching Path
instances directly.
const example = {
name: 'name',
array: [
{ name: 'one', value: 1 },
{ name: 'two', value: 2 },
],
};
Projection.of([PathMatcher.of('array')]).map(example);
Projection.of([], [PathMatcher.of('array')]).map(example);
Projection.of([PathMatcher.of('array')], [PathMatcher.of('array', AnyIndex, 'name')]).map(example);
Projection.of([PathMatcher.of('array', 1, 'name')]);
Projection.map
does not modify it's input, but returns a "JSON clone" - except in a case when there are neither includes nor excludes, in which case it returns the input object directly.
JSON clone is also essential for security reasons as it prevent's a malicious user from accessing internals of an object (e.g. Moment
).
Projection
can also match Path
instances directly, which can be used for example as an optimization for skipping fetching of an expensive relation.
if (projection.match(Path.of('array', AnyIndex, 'name'))) {
fetchNamesFor(result.array):
}
Why Yet Another "JsonPath" Library?
Gave up trying to find a library that satisfies all our requirements ¯\(ツ)/¯
- Security:
JsonPath
contains parts that are strictly NOT safe for handling untrusted user input (e.g. static-eval#security) - Encoding of bracket notation properties
- Bracket notation property encoding is not defined by the JsonPath specification
- JSON string encoding seems a logical choice, but that conflicts with single quote being the selected quote type for property names as there is no escape sequence for single quote in JSON string encoding
- While parser also supports single quotes, this library uses JSON string encoding with double quotes by default
- Clear separation of concrete paths and matchers
- Concrete paths are required by
v-validation
to point to a location of an invalid value - Matchers are required for example by an API for include/exclude functionality
- Clear separation of logic and data sturctures from parsers
- Functions are especially designed to fit our use cases