Skip to content

Commit fbb56b5

Browse files
authored
Respect RFC 6901, use ESM output, add new mergeKeys dereference option. (#398)
This changes the URL parsing behavior to actually respect RFC 6901. Previously we were URL encoding too often. This will be considered a breaking change, as it will change how previously parsed documents are bundled and dereferenced. This also adds a new mergeKey option. Additionally, the new output will be ESM. Should fix #383, #356, and makes progress on #370 BREAKING CHANGE: Change URL encoding to use strict RFC 6901, add new mergeKeys dereference option
1 parent 6a469b7 commit fbb56b5

File tree

31 files changed

+1234
-931
lines changed

31 files changed

+1234
-931
lines changed

.claude/settings.local.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"permissions": {
3+
"allow": ["Bash(npx vitest:*)", "Bash(bash:*)", "Bash(node test-debug.js:*)"]
4+
},
5+
"enableAllProjectMcpServers": false
6+
}

.yarn/releases/yarn-4.9.4.cjs renamed to .yarn/releases/yarn-4.11.0.cjs

Lines changed: 331 additions & 331 deletions
Large diffs are not rendered by default.

.yarnrc.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
nodeLinker: node-modules
22

3-
yarnPath: .yarn/releases/yarn-4.9.4.cjs
3+
yarnPath: .yarn/releases/yarn-4.11.0.cjs

CLAUDE.md

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Development Commands
6+
7+
### Building
8+
```bash
9+
yarn build # Compile TypeScript to dist/
10+
yarn typecheck # Run TypeScript type checking without emitting files
11+
```
12+
13+
### Testing
14+
```bash
15+
yarn test # Run all tests with coverage (Vitest)
16+
yarn test:node # Run tests in Node.js environment
17+
yarn test:browser # Run tests in browser environment (jsdom)
18+
yarn test:watch # Run tests in watch mode
19+
yarn test:update # Update test snapshots
20+
```
21+
22+
### Code Quality
23+
```bash
24+
yarn lint # Run ESLint on lib/ directory
25+
yarn prettier # Format all code files
26+
```
27+
28+
### Running Individual Tests
29+
To run a single test file:
30+
```bash
31+
npx vitest test/specs/circular/circular.spec.ts
32+
```
33+
34+
To run tests matching a pattern:
35+
```bash
36+
npx vitest --grep "circular"
37+
```
38+
39+
## Architecture Overview
40+
41+
### Core Purpose
42+
This library parses, resolves, and dereferences JSON Schema `$ref` pointers. It handles references to:
43+
- External files (local filesystem)
44+
- HTTP/HTTPS URLs
45+
- Internal JSON pointers within schemas
46+
- Mixed JSON and YAML formats
47+
- Circular/recursive references
48+
49+
### Key Architecture Components
50+
51+
#### 1. $RefParser (lib/index.ts)
52+
The main entry point and orchestrator class. Provides four primary operations:
53+
- **parse()**: Reads a single schema file (JSON/YAML) without resolving references
54+
- **resolve()**: Parses and resolves all `$ref` pointers, returns a `$Refs` object mapping references to values
55+
- **bundle()**: Converts external `$ref` pointers to internal ones (single file output)
56+
- **dereference()**: Replaces all `$ref` pointers with their actual values (fully expanded schema)
57+
58+
All methods support both callback and Promise-based APIs, with multiple overload signatures.
59+
60+
#### 2. $Refs (lib/refs.ts)
61+
A map/registry of all resolved JSON references and their values. Tracks:
62+
- All file paths/URLs encountered
63+
- Circular reference detection
64+
- Helper methods to query references by type (file, http, etc.)
65+
66+
#### 3. Pointer (lib/pointer.ts)
67+
Represents a single JSON pointer (`#/definitions/person`) and implements JSON Pointer RFC 6901 spec:
68+
- Parses JSON pointer syntax (`/`, `~0`, `~1` escaping)
69+
- Resolves pointers to actual values within objects
70+
- Handles edge cases (null values, missing properties)
71+
72+
#### 4. $Ref (lib/ref.ts)
73+
Wraps a single reference with metadata:
74+
- The reference path/URL
75+
- The resolved value
76+
- Path type (file, http, etc.)
77+
- Error information (when continueOnError is enabled)
78+
79+
#### 5. Plugin System
80+
Two types of plugins, both configurable via options:
81+
82+
**Parsers** (lib/parsers/):
83+
- JSON parser (json.ts)
84+
- YAML parser (yaml.ts) - uses js-yaml
85+
- Text parser (text.ts)
86+
- Binary parser (binary.ts)
87+
- Execute in order based on `order` property and `canParse()` matching
88+
89+
**Resolvers** (lib/resolvers/):
90+
- File resolver (file.ts) - reads from filesystem (Node.js only)
91+
- HTTP resolver (http.ts) - fetches from URLs using native fetch
92+
- Custom resolvers can be added via options
93+
- Execute in order based on `order` property and `canRead()` matching
94+
95+
#### 6. Core Operations
96+
97+
**lib/parse.ts**: Entry point for parsing a single schema file
98+
**lib/resolve-external.ts**: Crawls schema to find and resolve external `$ref` pointers
99+
**lib/bundle.ts**: Replaces external refs with internal refs
100+
**lib/dereference.ts**: Replaces all `$ref` pointers with actual values, handles circular references
101+
102+
#### 7. Options System (lib/options.ts)
103+
Hierarchical configuration with defaults for:
104+
- Which parsers/resolvers to enable
105+
- Circular reference handling (boolean or "ignore")
106+
- External reference resolution (relative vs root)
107+
- Continue on error mode (collect all errors vs fail fast)
108+
- Bundle/dereference callbacks and matchers
109+
- Input mutation control (mutateInputSchema)
110+
111+
### Key Design Patterns
112+
113+
1. **Flexible Arguments**: normalizeArgs() (lib/normalize-args.ts) unifies various call signatures into consistent internal format
114+
115+
2. **Path Handling**: Automatic conversion between filesystem paths and file:// URLs. Cross-platform support via util/url.ts and util/convert-path-to-posix.ts
116+
117+
3. **Error Handling**:
118+
- Fail-fast by default
119+
- Optional continueOnError mode collects errors in JSONParserErrorGroup
120+
- Specific error types: JSONParserError, InvalidPointerError, MissingPointerError, ResolverError, ParserError
121+
122+
4. **Circular Reference Management**:
123+
- Detected during dereference
124+
- Can throw error, ignore, or handle via dereference.circular option
125+
- Reference equality maintained (same `$ref` → same object instance)
126+
127+
5. **Browser/Node Compatibility**:
128+
- Uses native fetch (requires Node 18+)
129+
- File resolver disabled in browser builds (package.json browser field)
130+
- Tests run in both environments
131+
132+
## Testing Strategy
133+
134+
Tests are organized in test/specs/ by scenario:
135+
- Each scenario has test files (*.spec.ts) and fixture data
136+
- Tests validate parse, resolve, bundle, and dereference operations
137+
- Extensive coverage of edge cases: circular refs, deep nesting, special characters in paths
138+
- Browser-specific tests use test/fixtures/server.ts for HTTP mocking
139+
140+
Test utilities:
141+
- test/utils/helper.js: Common test patterns
142+
- test/utils/path.js: Path handling for cross-platform tests
143+
- test/utils/serializeJson.ts: Custom snapshot serializer
144+
145+
## Important Constraints
146+
147+
1. **TypeScript Strict Mode**: Project uses strict TypeScript including exactOptionalPropertyTypes
148+
2. **JSON Schema Support**: Compatible with JSON Schema v4, v6, and v7
149+
3. **Minimum Node Version**: Requires Node >= 20 (for native fetch support)
150+
4. **Circular JSON**: Dereferenced schemas may contain circular references (not JSON.stringify safe)
151+
5. **Path Normalization**: Always converts filesystem paths to POSIX format internally
152+
6. **URL Safety**: HTTP resolver has safeUrlResolver option to block internal URLs (default: unsafe allowed)

README.md

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,19 +29,15 @@ format. Maybe some of the files contain cross-references to each other.
2929
{
3030
"definitions": {
3131
"person": {
32-
// references an external file
3332
"$ref": "schemas/people/Bruce-Wayne.json"
3433
},
3534
"place": {
36-
// references a sub-schema in an external file
3735
"$ref": "schemas/places.yaml#/definitions/Gotham-City"
3836
},
3937
"thing": {
40-
// references a URL
4138
"$ref": "http://wayne-enterprises.com/things/batmobile"
4239
},
4340
"color": {
44-
// references a value in an external file via an internal reference
4541
"$ref": "#/definitions/thing/properties/colors/black-as-the-night"
4642
}
4743
}

lib/bundle.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ import $Ref from "./ref.js";
22
import Pointer from "./pointer.js";
33
import * as url from "./util/url.js";
44
import type $Refs from "./refs.js";
5-
import type $RefParser from "./index";
6-
import type { ParserOptions } from "./index";
7-
import type { JSONSchema } from "./index";
8-
import type { BundleOptions } from "./options";
5+
import type $RefParser from "./index.js";
6+
import type { ParserOptions } from "./index.js";
7+
import type { JSONSchema } from "./index.js";
8+
import type { BundleOptions } from "./options.js";
99

1010
export interface InventoryEntry {
1111
$ref: any;
@@ -40,7 +40,7 @@ function bundle<S extends object = JSONSchema, O extends ParserOptions<S> = Pars
4040
crawl<S, O>(parser, "schema", parser.$refs._root$Ref.path + "#", "#", 0, inventory, parser.$refs, options);
4141

4242
// Remap all $ref pointers
43-
remap(inventory);
43+
remap<S, O>(inventory, options);
4444
}
4545

4646
/**
@@ -203,7 +203,10 @@ function inventory$Ref<S extends object = JSONSchema, O extends ParserOptions<S>
203203
*
204204
* @param inventory
205205
*/
206-
function remap(inventory: InventoryEntry[]) {
206+
function remap<S extends object = JSONSchema, O extends ParserOptions<S> = ParserOptions<S>>(
207+
inventory: InventoryEntry[],
208+
options: O,
209+
) {
207210
// Group & sort all the $ref pointers, so they're in the order that we need to dereference/remap them
208211
inventory.sort((a: InventoryEntry, b: InventoryEntry) => {
209212
if (a.file !== b.file) {
@@ -254,10 +257,10 @@ function remap(inventory: InventoryEntry[]) {
254257
// This $ref already resolves to the main JSON Schema file
255258
entry.$ref.$ref = entry.hash;
256259
} else if (entry.file === file && entry.hash === hash) {
257-
// This $ref points to the same value as the prevous $ref, so remap it to the same path
260+
// This $ref points to the same value as the previous $ref, so remap it to the same path
258261
entry.$ref.$ref = pathFromRoot;
259262
} else if (entry.file === file && entry.hash.indexOf(hash + "/") === 0) {
260-
// This $ref points to a sub-value of the prevous $ref, so remap it beneath that path
263+
// This $ref points to a sub-value of the previous $ref, so remap it beneath that path
261264
entry.$ref.$ref = Pointer.join(pathFromRoot, Pointer.parse(entry.hash.replace(hash, "#")));
262265
} else {
263266
// We've moved to a new file or new hash
@@ -267,7 +270,7 @@ function remap(inventory: InventoryEntry[]) {
267270

268271
// This is the first $ref to point to this value, so dereference the value.
269272
// Any other $refs that point to the same value will point to this $ref instead
270-
entry.$ref = entry.parent[entry.key] = $Ref.dereference(entry.$ref, entry.value);
273+
entry.$ref = entry.parent[entry.key] = $Ref.dereference(entry.$ref, entry.value, options);
271274

272275
if (entry.circular) {
273276
// This $ref points to itself

lib/dereference.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,8 @@ import Pointer from "./pointer.js";
33
import * as url from "./util/url.js";
44
import type $Refs from "./refs.js";
55
import type { DereferenceOptions, ParserOptions } from "./options.js";
6-
import type { JSONSchema } from "./types";
7-
import type $RefParser from "./index";
8-
import { TimeoutError } from "./util/errors";
6+
import { type $RefParser, type JSONSchema } from "./index.js";
7+
import { TimeoutError } from "./util/errors.js";
98

109
export default dereference;
1110

@@ -278,7 +277,7 @@ function dereference$Ref<S extends object = JSONSchema, O extends ParserOptions<
278277
}
279278

280279
// Dereference the JSON reference
281-
let dereferencedValue = $Ref.dereference($ref, pointer.value);
280+
let dereferencedValue = $Ref.dereference($ref, pointer.value, options);
282281

283282
// Crawl the dereferenced value (unless it's circular)
284283
if (!circular) {

lib/normalize-args.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Options, ParserOptions } from "./options.js";
22
import { getNewOptions } from "./options.js";
3-
import type { JSONSchema, SchemaCallback } from "./types";
3+
import type { JSONSchema, SchemaCallback } from "./index.js";
44

55
// I really dislike this function and the way it's written. It's not clear what it's doing, and it's way too flexible
66
// In the future, I'd like to deprecate the api and accept only named parameters in index.ts

lib/options.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,13 @@ export interface DereferenceOptions {
8282
* Default: `relative`
8383
*/
8484
externalReferenceResolution?: "relative" | "root";
85+
86+
/**
87+
* Whether duplicate keys should be merged when dereferencing objects.
88+
*
89+
* Default: `true`
90+
*/
91+
mergeKeys?: boolean;
8592
}
8693

8794
/**
@@ -229,6 +236,7 @@ export const getJsonSchemaRefParserDefaultOptions = () => {
229236
*/
230237
excludedPathMatcher: () => false,
231238
referenceResolution: "relative",
239+
mergeKeys: true,
232240
},
233241

234242
mutateInputSchema: true,

0 commit comments

Comments
 (0)