Skip to content

Commit b9f91b2

Browse files
authored
Fix windows paths and external refs (#321)
* chore: bump deps * chore: bump deps * add initial code for relative path fixing * feat: run crawling even when external ref is true BREAKING CHANGE: Change the file path parsing logic (to be safe)
1 parent a5b3946 commit b9f91b2

16 files changed

+1404
-899
lines changed

README.md

+22
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,28 @@ JSON Schema $Ref Parser supports recent versions of every major web browser. Ol
124124
To use JSON Schema $Ref Parser in a browser, you'll need to use a bundling tool such as [Webpack](https://webpack.js.org/), [Rollup](https://rollupjs.org/), [Parcel](https://parceljs.org/), or [Browserify](http://browserify.org/). Some bundlers may require a bit of configuration, such as setting `browser: true` in [rollup-plugin-resolve](https://github.com/rollup/rollup-plugin-node-resolve).
125125

126126

127+
#### Webpack 5
128+
Webpack 5 has dropped the default export of node core modules in favour of polyfills, you'll need to set them up yourself ( after npm-installing them )
129+
Edit your `webpack.config.js` :
130+
```js
131+
config.resolve.fallback = {
132+
"path": require.resolve("path-browserify"),
133+
'util': require.resolve('util/'),
134+
'fs': require.resolve('browserify-fs'),
135+
"buffer": require.resolve("buffer/"),
136+
"http": require.resolve("stream-http"),
137+
"https": require.resolve("https-browserify"),
138+
"url": require.resolve("url"),
139+
}
140+
141+
config.plugins.push(
142+
new webpack.ProvidePlugin({
143+
Buffer: [ 'buffer', 'Buffer']
144+
})
145+
)
146+
147+
```
148+
127149

128150
API Documentation
129151
--------------------------

lib/bundle.ts

+1
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ function crawl(
9494
* @param $refParent - The object that contains a JSON Reference as one of its keys
9595
* @param $refKey - The key in `$refParent` that is a JSON Reference
9696
* @param path - The full path of the JSON Reference at `$refKey`, possibly with a JSON Pointer in the hash
97+
* @param indirections - unknown
9798
* @param pathFromRoot - The path of the JSON Reference at `$refKey`, from the schema root
9899
* @param inventory - An array of already-inventoried $ref pointers
99100
* @param $refs

lib/ref.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { InvalidPointerError, isHandledError, normalizeError } from "./util/erro
44
import { safePointerToPath, stripHash, getHash } from "./util/url.js";
55
import type $Refs from "./refs.js";
66
import type $RefParserOptions from "./options.js";
7-
import type { JSONSchema } from "./types";
87

98
type $RefError = JSONParserError | ResolverError | ParserError | MissingPointerError;
109

@@ -167,7 +166,7 @@ class $Ref {
167166
* @param value - The value to inspect
168167
* @returns
169168
*/
170-
static isExternal$Ref(value: any): value is JSONSchema {
169+
static isExternal$Ref(value: any): boolean {
171170
return $Ref.is$Ref(value) && value.$ref![0] !== "#";
172171
}
173172

lib/refs.ts

+3-5
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,7 @@ import * as url from "./util/url.js";
44
import type { JSONSchema4Type, JSONSchema6Type, JSONSchema7Type } from "json-schema";
55
import type { JSONSchema } from "./types/index.js";
66
import type $RefParserOptions from "./options.js";
7-
8-
const isWindows = /^win/.test(globalThis.process ? globalThis.process.platform : "");
9-
const getPathFromOs = (filePath: string): string => (isWindows ? filePath.replace(/\\/g, "/") : filePath);
7+
import convertPathToPosix from "./util/convert-path-to-posix";
108

119
interface $RefsMap {
1210
[url: string]: $Ref;
@@ -36,7 +34,7 @@ export default class $Refs {
3634
paths(...types: string[]): string[] {
3735
const paths = getPaths(this._$refs, types);
3836
return paths.map((path) => {
39-
return getPathFromOs(path.decoded);
37+
return convertPathToPosix(path.decoded);
4038
});
4139
}
4240

@@ -51,7 +49,7 @@ export default class $Refs {
5149
const $refs = this._$refs;
5250
const paths = getPaths($refs, types);
5351
return paths.reduce<Record<string, any>>((obj, path) => {
54-
obj[getPathFromOs(path.decoded)] = $refs[path.encoded].value;
52+
obj[convertPathToPosix(path.decoded)] = $refs[path.encoded].value;
5553
return obj;
5654
}, {});
5755
}

lib/resolve-external.ts

+12-12
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ function resolveExternal(parser: $RefParser, options: Options) {
4040
*
4141
* @param obj - The value to crawl. If it's not an object or array, it will be ignored.
4242
* @param path - The full path of `obj`, possibly with a JSON Pointer in the hash
43+
* @param {boolean} external - Whether `obj` was found in an external document.
4344
* @param $refs
4445
* @param options
4546
* @param seen - Internal.
@@ -56,6 +57,7 @@ function crawl(
5657
$refs: $Refs,
5758
options: Options,
5859
seen?: Set<any>,
60+
external?: boolean,
5961
) {
6062
seen ||= new Set();
6163
let promises: any = [];
@@ -64,17 +66,13 @@ function crawl(
6466
seen.add(obj); // Track previously seen objects to avoid infinite recursion
6567
if ($Ref.isExternal$Ref(obj)) {
6668
promises.push(resolve$Ref(obj, path, $refs, options));
67-
} else {
68-
for (const key of Object.keys(obj)) {
69-
const keyPath = Pointer.join(path, key);
70-
const value = obj[key] as string | JSONSchema | Buffer | undefined;
71-
72-
if ($Ref.isExternal$Ref(value)) {
73-
promises.push(resolve$Ref(value, keyPath, $refs, options));
74-
} else {
75-
promises = promises.concat(crawl(value, keyPath, $refs, options, seen));
76-
}
77-
}
69+
}
70+
71+
const keys = Object.keys(obj) as (keyof typeof obj)[];
72+
for (const key of keys) {
73+
const keyPath = Pointer.join(path, key);
74+
const value = obj[key] as string | JSONSchema | Buffer | undefined;
75+
promises = promises.concat(crawl(value, keyPath, $refs, options, seen, external));
7876
}
7977
}
8078

@@ -99,6 +97,8 @@ async function resolve$Ref($ref: JSONSchema, path: string, $refs: $Refs, options
9997
const resolvedPath = url.resolve(path, $ref.$ref);
10098
const withoutHash = url.stripHash(resolvedPath);
10199

100+
// $ref.$ref = url.relative($refs._root$Ref.path, resolvedPath);
101+
102102
// Do we already have this $ref?
103103
$ref = $refs._$refs[withoutHash];
104104
if ($ref) {
@@ -112,7 +112,7 @@ async function resolve$Ref($ref: JSONSchema, path: string, $refs: $Refs, options
112112

113113
// Crawl the parsed value
114114
// console.log('Resolving $ref pointers in %s', withoutHash);
115-
const promises = crawl(result, withoutHash + "#", $refs, options);
115+
const promises = crawl(result, withoutHash + "#", $refs, options, new Set(), true);
116116

117117
return Promise.all(promises);
118118
} catch (err) {

lib/resolvers/file.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import fs from "fs/promises";
1+
import { promises as fs } from "fs";
22
import { ono } from "@jsdevtools/ono";
33
import * as url from "../util/url.js";
44
import { ResolverError } from "../util/errors.js";

lib/util/convert-path-to-posix.ts

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import path from "path";
2+
3+
export default function convertPathToPosix(filePath: string) {
4+
const isExtendedLengthPath = filePath.startsWith("\\\\?\\");
5+
6+
if (isExtendedLengthPath) {
7+
return filePath;
8+
}
9+
10+
return filePath.split(path.win32.sep).join(path.posix.sep);
11+
}

lib/util/is-windows.ts

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
const isWindowsConst = /^win/.test(globalThis.process ? globalThis.process.platform : "");
2+
export const isWindows = () => isWindowsConst;

lib/util/url.ts

+38-18
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1-
const isWindows = /^win/.test(globalThis.process ? globalThis.process.platform : ""),
2-
forwardSlashPattern = /\//g,
3-
protocolPattern = /^(\w{2,}):\/\//i,
4-
jsonPointerSlash = /~1/g,
5-
jsonPointerTilde = /~0/g;
1+
import convertPathToPosix from "./convert-path-to-posix";
2+
import path, { win32 } from "path";
3+
4+
const forwardSlashPattern = /\//g;
5+
const protocolPattern = /^(\w{2,}):\/\//i;
6+
const jsonPointerSlash = /~1/g;
7+
const jsonPointerTilde = /~0/g;
8+
69
import { join } from "path";
10+
import { isWindows } from "./is-windows";
711

812
const projectDir = join(__dirname, "..", "..");
913
// RegExp patterns to URL-encode special characters in local filesystem paths
@@ -55,8 +59,8 @@ export function cwd() {
5559
* @param path
5660
* @returns
5761
*/
58-
export function getProtocol(path: any) {
59-
const match = protocolPattern.exec(path);
62+
export function getProtocol(path: string | undefined) {
63+
const match = protocolPattern.exec(path || "");
6064
if (match) {
6165
return match[1].toLowerCase();
6266
}
@@ -146,7 +150,7 @@ export function isHttp(path: any) {
146150
* @param path
147151
* @returns
148152
*/
149-
export function isFileSystemPath(path: any) {
153+
export function isFileSystemPath(path: string | undefined) {
150154
// @ts-ignore
151155
if (typeof window !== "undefined" || process.browser) {
152156
// We're running in a browser, so assume that all paths are URLs.
@@ -177,14 +181,18 @@ export function isFileSystemPath(path: any) {
177181
export function fromFileSystemPath(path: any) {
178182
// Step 1: On Windows, replace backslashes with forward slashes,
179183
// rather than encoding them as "%5C"
180-
if (isWindows) {
181-
const hasProjectDir = path.toUpperCase().includes(projectDir.replace(/\\/g, "\\").toUpperCase());
182-
const hasProjectUri = path.toUpperCase().includes(projectDir.replace(/\\/g, "/").toUpperCase());
183-
if (hasProjectDir || hasProjectUri) {
184-
path = path.replace(/\\/g, "/");
185-
} else {
186-
path = `${projectDir}/${path}`.replace(/\\/g, "/");
184+
if (isWindows()) {
185+
const upperPath = path.toUpperCase();
186+
const projectDirPosixPath = convertPathToPosix(projectDir);
187+
const posixUpper = projectDirPosixPath.toUpperCase();
188+
const hasProjectDir = upperPath.includes(posixUpper);
189+
const hasProjectUri = upperPath.includes(posixUpper);
190+
const isAbsolutePath = win32.isAbsolute(path);
191+
192+
if (!(hasProjectDir || hasProjectUri || isAbsolutePath)) {
193+
path = join(projectDir, path);
187194
}
195+
path = convertPathToPosix(path);
188196
}
189197

190198
// Step 2: `encodeURI` will take care of MOST characters
@@ -222,7 +230,7 @@ export function toFileSystemPath(path: string | undefined, keepFileProtocol?: bo
222230
path = path[7] === "/" ? path.substr(8) : path.substr(7);
223231

224232
// insert a colon (":") after the drive letter on Windows
225-
if (isWindows && path[1] === "/") {
233+
if (isWindows() && path[1] === "/") {
226234
path = path[0] + ":" + path.substr(1);
227235
}
228236

@@ -234,12 +242,12 @@ export function toFileSystemPath(path: string | undefined, keepFileProtocol?: bo
234242
// On Windows, it will start with something like "C:/".
235243
// On Posix, it will start with "/"
236244
isFileUrl = false;
237-
path = isWindows ? path : "/" + path;
245+
path = isWindows() ? path : "/" + path;
238246
}
239247
}
240248

241249
// Step 4: Normalize Windows paths (unless it's a "file://" URL)
242-
if (isWindows && !isFileUrl) {
250+
if (isWindows() && !isFileUrl) {
243251
// Replace forward slashes with backslashes
244252
path = path.replace(forwardSlashPattern, "\\");
245253

@@ -270,3 +278,15 @@ export function safePointerToPath(pointer: any) {
270278
return decodeURIComponent(value).replace(jsonPointerSlash, "/").replace(jsonPointerTilde, "~");
271279
});
272280
}
281+
282+
export function relative(from: string | undefined, to: string | undefined) {
283+
if (!isFileSystemPath(from) || !isFileSystemPath(to)) {
284+
return resolve(from, to);
285+
}
286+
287+
const fromDir = path.dirname(stripHash(from));
288+
const toPath = stripHash(to);
289+
290+
const result = path.relative(fromDir, toPath);
291+
return result + getHash(to);
292+
}

package.json

+20-21
Original file line numberDiff line numberDiff line change
@@ -67,33 +67,32 @@
6767
"test:watch": "vitest -w"
6868
},
6969
"devDependencies": {
70-
"@types/eslint": "8.4.10",
71-
"@types/js-yaml": "^4.0.5",
72-
"@types/node": "^18.11.18",
73-
"@typescript-eslint/eslint-plugin": "^5.48.2",
74-
"@typescript-eslint/eslint-plugin-tslint": "^5.48.2",
75-
"@typescript-eslint/parser": "^5.48.2",
76-
"@vitest/coverage-c8": "^0.28.1",
70+
"@types/eslint": "8.44.2",
71+
"@types/js-yaml": "^4.0.6",
72+
"@types/node": "^20.6.2",
73+
"@typescript-eslint/eslint-plugin": "^6.7.2",
74+
"@typescript-eslint/eslint-plugin-tslint": "^6.7.2",
75+
"@typescript-eslint/parser": "^6.7.2",
76+
"@vitest/coverage-v8": "^0.34.4",
7777
"abortcontroller-polyfill": "^1.7.5",
78-
"c8": "^7.12.0",
7978
"cross-env": "^7.0.3",
80-
"eslint": "^8.32.0",
81-
"eslint-config-prettier": "^8.6.0",
82-
"eslint-config-standard": "^17.0.0",
83-
"eslint-plugin-import": "^2.27.5",
84-
"eslint-plugin-prettier": "^4.2.1",
79+
"eslint": "^8.49.0",
80+
"eslint-config-prettier": "^9.0.0",
81+
"eslint-config-standard": "^17.1.0",
82+
"eslint-plugin-import": "^2.28.1",
83+
"eslint-plugin-prettier": "^5.0.0",
8584
"eslint-plugin-promise": "^6.1.1",
86-
"eslint-plugin-unused-imports": "^2.0.0",
87-
"jsdom": "^21.1.0",
88-
"lint-staged": "^13.1.0",
89-
"node-fetch": "^3.3.0",
90-
"prettier": "^2.8.3",
91-
"typescript": "^4.9.4",
92-
"vitest": "^0.28.1"
85+
"eslint-plugin-unused-imports": "^3.0.0",
86+
"jsdom": "^22.1.0",
87+
"lint-staged": "^14.0.1",
88+
"node-fetch": "^3.3.2",
89+
"prettier": "^3.0.3",
90+
"typescript": "^5.2.2",
91+
"vitest": "^0.34.4"
9392
},
9493
"dependencies": {
9594
"@jsdevtools/ono": "^7.1.3",
96-
"@types/json-schema": "^7.0.11",
95+
"@types/json-schema": "^7.0.13",
9796
"@types/lodash.clonedeep": "^4.5.7",
9897
"js-yaml": "^4.1.0",
9998
"lodash.clonedeep": "^4.5.0"

test/specs/util/url.spec.ts

+52-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { describe, it } from "vitest";
2-
import { expect } from "vitest";
1+
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
32
import * as $url from "../../../lib/util/url.js";
4-
3+
import * as isWin from "../../../lib/util/is-windows";
4+
import convertPathToPosix from "../../../lib/util/convert-path-to-posix";
55
describe("Return the extension of a URL", () => {
66
it("should return an empty string if there isn't any extension", async () => {
77
const extension = $url.getExtension("/file");
@@ -18,3 +18,52 @@ describe("Return the extension of a URL", () => {
1818
expect(extension).to.equal(".yml");
1919
});
2020
});
21+
describe("Handle Windows file paths", () => {
22+
beforeAll(function (this: any) {
23+
vi.spyOn(isWin, "isWindows").mockReturnValue(true);
24+
});
25+
26+
afterAll(function (this: any) {
27+
vi.restoreAllMocks();
28+
});
29+
30+
it("should handle absolute paths", async () => {
31+
const result = $url.fromFileSystemPath("Y:\\A\\Random\\Path\\file.json");
32+
expect(result)
33+
.to.be.a("string")
34+
.and.toSatisfy((msg: string) => msg.startsWith("Y:/A/Random/Path"));
35+
});
36+
37+
it("should handle relative paths", async () => {
38+
const result = $url.fromFileSystemPath("Path\\file.json");
39+
const pwd = convertPathToPosix(process.cwd());
40+
expect(result)
41+
.to.be.a("string")
42+
.and.toSatisfy((msg: string) => msg.startsWith(pwd));
43+
});
44+
});
45+
46+
describe("Handle Linux file paths", () => {
47+
beforeAll(function (this: any) {
48+
//Force isWindows to always be false for this section of the test
49+
vi.spyOn(isWin, "isWindows").mockReturnValue(false);
50+
});
51+
52+
afterAll(function (this: any) {
53+
vi.restoreAllMocks();
54+
});
55+
56+
it("should handle absolute paths", async () => {
57+
const result = $url.fromFileSystemPath("/a/random/Path/file.json");
58+
expect(result)
59+
.to.be.a("string")
60+
.and.toSatisfy((msg: string) => msg.startsWith("/a/random/Path/file.json"));
61+
});
62+
63+
it("should handle relative paths", async () => {
64+
const result = $url.fromFileSystemPath("Path/file.json");
65+
expect(result)
66+
.to.be.a("string")
67+
.and.toSatisfy((msg: string) => msg.startsWith("Path/file.json"));
68+
});
69+
});

test/tsconfig.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"declaration": true,
1111
"esModuleInterop": true,
1212
"inlineSourceMap": false,
13-
"lib": ["esnext", "dom"],
13+
"lib": ["esnext", "dom", "DOM"],
1414
"listEmittedFiles": false,
1515
"listFiles": false,
1616
"moduleResolution": "node16",

0 commit comments

Comments
 (0)