Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .ai-class
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
green
100 changes: 100 additions & 0 deletions .github/workflows/main.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
name: Main CI

on: [push]

permissions: {}

jobs:
lint:
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
matrix:
node-version: [24.x]
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node-version }}
- run: npm install
- name: Run eslint
run: npm run lint
test-node:
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
matrix:
node-version: [22.x, 24.x, 26.x]
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Install with Node.js 24.x
uses: actions/setup-node@v7
with:
node-version: 24.x
- run: npm install
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node-version }}
- name: Run tests with Node.js ${{ matrix.node-version }}
run: npm run test-node
test-browser:
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
matrix:
node-version: [24.x]
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node-version }}
- run: npm install
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package.json') }}
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run browser tests
run: npm run test-browser
coverage:
runs-on: ubuntu-latest
# coverage runs the browser project too, so a browser is required
timeout-minutes: 15
strategy:
matrix:
node-version: [24.x]
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node-version }}
- run: npm install
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package.json') }}
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Generate coverage report
run: npm run coverage-ci
- name: Upload coverage to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
files: ./coverage/lcov.info
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
16 changes: 16 additions & 0 deletions .gitignore
Comment thread
applesnort marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
node_modules
coverage
.nyc_output
reports
.cache
scratchpad/
*.log
*.tgz
.eslintcache
.vscode
.project
.settings
TAGS
*~
*.sw[nop]
.DS_Store
1 change: 1 addition & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
package-lock=false
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# @digitalbazaar/json-pointer-primitives ChangeLog

## 1.0.0 - TBD

### Added
- Initial release.
- `matches({object, map})` — tests any object against a map of JSON pointers to
expected values. Every entry must match; a `Set` as a value means any of its
members may match; an empty `Map`, `Set` or string is a wildcard. An array is
the ordered `@context` case, where each element must equal the element at the
same index and the object may carry more.
- Prefer an empty `Map` as the wildcard — `{}` in a JSON-LD example, matching
JSON-LD Framing. The empty string means the same for QueryByExample
compatibility, but conflates "any value" with "the empty string".
- `toJsonPointerMap({obj, flat})` and `fromJsonPointerMap({map})` — build a
pointer map from an object and rebuild an object from one. Non-flat output
expresses arrays as a `Set`, except at `@context`, which stays ordered.
- `resolvePointer(obj, pointer)` — reads one pointer against own properties.
- `isObject`, `isNumber`, `toIntegerIfInteger`, `assert`.
26 changes: 26 additions & 0 deletions LICENSE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
Copyright (c) 2026 Digital Bazaar, Inc.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:

1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.

2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.

3. Neither the name of the copyright holder nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
108 changes: 108 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# @digitalbazaar/json-pointer-primitives

Building blocks for matching objects against JSON pointers: convert a nested
object into a map of pointers to values, resolve a pointer, and test whether an
object satisfies such a map.

It does not know what it is matching. Give it any object and any map.

```js
import {matches} from '@digitalbazaar/json-pointer-primitives';

matches({
object: credential,
map: new Map([
['/type', 'MovieTicketCredential'],
['/issuer/id', 'did:example:issuer']
])
});
// true — every entry matched
```

One runtime dependency, `json-pointer`. Runs on node and in a browser.

## Semantics
Comment thread
applesnort marked this conversation as resolved.

Every entry in the map must match, so entries are a conjunction:

```js
const credential = {
type: ['VerifiableCredential', 'MovieTicketCredential'],
issuer: {id: 'did:example:issuer'},
credentialSubject: {seat: 'A1', row: 12}
};

matches({object: credential, map: new Map([
['/issuer/id', 'did:example:issuer'],
['/credentialSubject/seat', 'A1']
])}); // true

matches({object: credential, map: new Map([
['/issuer/id', 'did:example:issuer'],
['/credentialSubject/seat', 'B2']
])}); // false — one entry failed
```

A `Set` value is an alternation, nested inside that conjunction — any member
may match:

```js
new Map([['/credentialSubject/seat', new Set(['A1', 'B2'])]]); // either seat
```

A pointer that resolves to an array matches if any element does:

```js
new Map([['/type', 'MovieTicketCredential']]); // true — one of two types
```

An empty `Map` is a wildcard: any value, so long as the pointer resolves. An
empty `Set` and `''` mean the same; prefer the empty `Map`, which is what `{}`
in an example becomes.

```js
new Map([['/issuer/id', new Map()]]); // true — an issuer id exists
new Map([['/absent', new Map()]]); // false — nothing there
```

A numeric string and a number compare equal by default:

```js
new Map([['/credentialSubject/row', '12']]); // true
matches({object: credential, map, options: {coerceNumbers: false}}); // false
```

`@context` is the exception to array handling: it stays ordered, and each
element must equal the element at the same index. The object may carry more.

A pointer that resolves to nothing never matches. Pointers read own properties
only, so `/constructor` and `/toString` resolve to nothing.

## Building a map

From a nested example that mirrors the object's shape:

```js
toJsonPointerMap({obj: {credentialSubject: {name: 'John Doe'}}});
// Map { '/credentialSubject/name' => 'John Doe' }
```

or by hand, from flat pointers:

```js
new Map([
['/renderSuite', 'html'],
['/template/mediaType', 'text/html']
]);
```

`fromJsonPointerMap({map})` rebuilds an object from a map.

```js
resolvePointer({issuer: {id: 'did:example:issuer'}}, '/issuer/id');
// 'did:example:issuer'
```

## License

See [LICENSE.md](./LICENSE.md).
9 changes: 9 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/*!
* Copyright (c) 2026 Digital Bazaar, Inc.
*/
import universalConfig
from '@digitalbazaar/eslint-config/universal-recommended';

export default [
...universalConfig
];
8 changes: 8 additions & 0 deletions lib/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/*!
* Copyright (c) 2026 Digital Bazaar, Inc.
*/
export {matches} from './match.js';
export {
assert, fromJsonPointerMap, isNumber, isObject, resolvePointer,
toIntegerIfInteger, toJsonPointerMap
} from './util.js';
98 changes: 98 additions & 0 deletions lib/match.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/*!
* Copyright (c) 2025-2026 Digital Bazaar, Inc.
*/
import {assert, isObject, resolvePointer, toIntegerIfInteger} from './util.js';

/**
* Returns whether an object matches against a JSON pointer map.
*
* The map is a `Map` of JSON pointer to expected value. Every entry must
* match. A `Set` as a value means any of its members may match, so alternation
* nests inside conjunction.
*
* An empty `Map`, `Set` or string is a wildcard: any value, but the pointer
* must still resolve. Prefer the empty `Map` -- `{}` in a JSON-LD example.
* The empty string means the same, for QueryByExample compatibility.
*
* Nothing here knows what it is matching. Callers build the map from whatever
* they have -- a QueryByExample `example`, a DCQL credential query, a
* Presentation Exchange input descriptor, or a hand-written set of pointers --
* and match credentials, render methods or anything else against it. Building
* the map once and reusing it across candidates is the cheaper order.
*
* @param {object} options - The options.
* @param {object} options.object - The object to try to match.
* @param {Map} options.map - The JSON pointer map.
* @param {object} [options.options] - Match options:
* [coerceNumbers=true] - Numeric strings and numbers compare equal.
*
* @returns {boolean} `true` if the object matches, `false` if not.
*/
export function matches({object, map, options = {coerceNumbers: true}} = {}) {
Comment thread
dlongley marked this conversation as resolved.
// a bad `map` must not read as "matched everything"
assert(map, 'map', Map);
// only an object can match
if(!isObject(object)) {
return false;
}
return _match({cursor: object, matchValue: map, options});
}

function _match({cursor, matchValue, options}) {
// handle wildcard matching
if(_isWildcard(matchValue)) {
return true;
}

if(matchValue instanceof Set) {
// some element in the set must match `cursor`
return [...matchValue].some(e => _match({cursor, matchValue: e, options}));
}

if(matchValue instanceof Map) {
// all pointers and values in the map must match `cursor`
return [...matchValue.entries()].every(([pointer, matchValue]) => {
const value = resolvePointer(cursor, pointer);
if(value === undefined) {
// no value at `pointer`; no match
return false;
}
// handles case where `value` is an empty array + wildcard `matchValue`
if(_isWildcard(matchValue)) {
return true;
}
// normalize value to an array for matching
const values = Array.isArray(value) ? value : [value];
// `matchValue` can only be an array for the `@context` case
if(Array.isArray(matchValue)) {
// each element of `matchValue` must be equal to the element in
// `values` at the same index (note: `values` may have more elements
// than `matchValue` and still match)
return matchValue.every((mv, i) => values[i] === mv);
}
// handle matching each individual value on its own
return values.some(v => _match({cursor: v, matchValue, options}));
});
}

// primitive comparison
if(cursor === matchValue) {
return true;
}

// string/number coercion
if(options.coerceNumbers) {
const cursorNumber = toIntegerIfInteger(cursor);
const matchNumber = toIntegerIfInteger(matchValue);
return cursorNumber !== undefined && cursorNumber === matchNumber;
}

return false;
}

function _isWildcard(value) {
// by type, not a bare `size`, which any object can carry
return value === '' ||
(value instanceof Map && value.size === 0) ||
(value instanceof Set && value.size === 0);
}
Loading
Loading