@hey-api/json-schema-ref-parser 0.0.0-next-20260212230650

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,99 @@
1
+ import { ono } from '@jsdevtools/ono';
2
+
3
+ import type { FileInfo } from '../types';
4
+ import { ResolverError } from '../util/errors';
5
+ import { resolve } from '../util/url';
6
+
7
+ export const sendRequest = async ({
8
+ fetchOptions,
9
+ redirects = [],
10
+ timeout = 60_000,
11
+ url,
12
+ }: {
13
+ fetchOptions?: RequestInit;
14
+ redirects?: string[];
15
+ timeout?: number;
16
+ url: URL | string;
17
+ }): Promise<{
18
+ fetchOptions?: RequestInit;
19
+ response: Response;
20
+ }> => {
21
+ url = new URL(url);
22
+ redirects.push(url.href);
23
+
24
+ const controller = new AbortController();
25
+ const timeoutId = setTimeout(() => {
26
+ controller.abort();
27
+ }, timeout);
28
+ const response = await fetch(url, {
29
+ signal: controller.signal,
30
+ ...fetchOptions,
31
+ });
32
+ clearTimeout(timeoutId);
33
+
34
+ if (response.status >= 300 && response.status <= 399) {
35
+ if (redirects.length > 5) {
36
+ throw new ResolverError(
37
+ ono(
38
+ { status: response.status },
39
+ `Error requesting ${redirects[0]}. \nToo many redirects: \n ${redirects.join(' \n ')}`,
40
+ ),
41
+ );
42
+ }
43
+
44
+ if (!('location' in response.headers) || !response.headers.location) {
45
+ throw ono(
46
+ { status: response.status },
47
+ `HTTP ${response.status} redirect with no location header`,
48
+ );
49
+ }
50
+
51
+ return sendRequest({
52
+ fetchOptions,
53
+ redirects,
54
+ timeout,
55
+ url: resolve(url.href, response.headers.location as string),
56
+ });
57
+ }
58
+
59
+ return { fetchOptions, response };
60
+ };
61
+
62
+ export const urlResolver = {
63
+ handler: async ({
64
+ arrayBuffer,
65
+ fetch: _fetch,
66
+ file,
67
+ }: {
68
+ arrayBuffer?: ArrayBuffer;
69
+ fetch?: RequestInit;
70
+ file: FileInfo;
71
+ }): Promise<void> => {
72
+ let data = arrayBuffer;
73
+
74
+ if (!data) {
75
+ try {
76
+ const { fetchOptions, response } = await sendRequest({
77
+ fetchOptions: {
78
+ method: 'GET',
79
+ ..._fetch,
80
+ },
81
+ url: file.url,
82
+ });
83
+
84
+ if (response.status >= 400) {
85
+ // gracefully handle HEAD method not allowed
86
+ if (response.status !== 405 || fetchOptions?.method !== 'HEAD') {
87
+ throw ono({ status: response.status }, `HTTP ERROR ${response.status}`);
88
+ }
89
+ }
90
+
91
+ data = response.body ? await response.arrayBuffer() : new ArrayBuffer(0);
92
+ } catch (error: any) {
93
+ throw new ResolverError(ono(error, `Error requesting ${file.url}`), file.url);
94
+ }
95
+ }
96
+
97
+ file.data = Buffer.from(data!);
98
+ },
99
+ };
@@ -0,0 +1,58 @@
1
+ import type {
2
+ JSONSchema4,
3
+ JSONSchema4Object,
4
+ JSONSchema6,
5
+ JSONSchema6Object,
6
+ JSONSchema7,
7
+ JSONSchema7Object,
8
+ } from 'json-schema';
9
+
10
+ export type JSONSchema = JSONSchema4 | JSONSchema6 | JSONSchema7;
11
+ export type JSONSchemaObject = JSONSchema4Object | JSONSchema6Object | JSONSchema7Object;
12
+
13
+ export interface Plugin {
14
+ /**
15
+ * Can this parser be used to process this file?
16
+ */
17
+ canHandle: (file: FileInfo) => boolean;
18
+ /**
19
+ * This is where the real work of a parser happens. The `parse` method accepts the same file info object as the `canHandle` function, but rather than returning a boolean value, the `parse` method should return a JavaScript representation of the file contents. For our CSV parser, that is a two-dimensional array of lines and values. For your parser, it might be an object, a string, a custom class, or anything else.
20
+ *
21
+ * Unlike the `canHandle` function, the `parse` method can also be asynchronous. This might be important if your parser needs to retrieve data from a database or if it relies on an external HTTP service to return the parsed value. You can return your asynchronous value via a [Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) or a Node.js-style error-first callback. Here are examples of both approaches:
22
+ */
23
+ handler: (
24
+ file: FileInfo,
25
+ ) =>
26
+ | string
27
+ | Buffer
28
+ | JSONSchema
29
+ | Promise<{ data: Buffer }>
30
+ | Promise<string | Buffer | JSONSchema>;
31
+ name: 'binary' | 'file' | 'http' | 'json' | 'text' | 'yaml';
32
+ }
33
+
34
+ /**
35
+ * JSON Schema `$Ref` Parser supports plug-ins, such as resolvers and parsers. These plug-ins can have methods such as `canHandle()`, `read()`, `canHandle()`, and `parse()`. All of these methods accept the same object as their parameter: an object containing information about the file being read or parsed.
36
+ *
37
+ * The file info object currently only consists of a few properties, but it may grow in the future if plug-ins end up needing more information.
38
+ *
39
+ * See https://apitools.dev/json-schema-ref-parser/docs/plugins/file-info-object.html
40
+ */
41
+ export interface FileInfo {
42
+ /**
43
+ * The raw file contents, in whatever form they were returned by the resolver that read the file.
44
+ */
45
+ data: string | Buffer;
46
+ /**
47
+ * The lowercase file extension, such as ".json", ".yaml", ".txt", etc.
48
+ */
49
+ extension: string;
50
+ /**
51
+ * The hash (URL fragment) of the file URL, including the # symbol. If the URL doesn't have a hash, then this will be an empty string.
52
+ */
53
+ hash: string;
54
+ /**
55
+ * The full URL of the file. This could be any type of URL, including "http://", "https://", "file://", "ftp://", "mongodb://", or even a local filesystem path (when running in Node.js).
56
+ */
57
+ url: string;
58
+ }
@@ -0,0 +1,8 @@
1
+ export default function convertPathToPosix(filePath: string): string {
2
+ // Extended-length paths on Windows should not be converted
3
+ if (filePath.startsWith('\\\\?\\')) {
4
+ return filePath;
5
+ }
6
+
7
+ return filePath.replaceAll('\\', '/');
8
+ }
@@ -0,0 +1,155 @@
1
+ import { Ono } from '@jsdevtools/ono';
2
+
3
+ import type { $RefParser } from '..';
4
+ import type $Ref from '../ref';
5
+ import type { JSONSchema } from '../types';
6
+ import { getHash, stripHash, toFileSystemPath } from './url';
7
+
8
+ export type JSONParserErrorType =
9
+ | 'EUNKNOWN'
10
+ | 'EPARSER'
11
+ | 'EUNMATCHEDPARSER'
12
+ | 'ETIMEOUT'
13
+ | 'ERESOLVER'
14
+ | 'EUNMATCHEDRESOLVER'
15
+ | 'EMISSINGPOINTER'
16
+ | 'EINVALIDPOINTER';
17
+
18
+ export class JSONParserError extends Error {
19
+ public readonly name: string;
20
+ public readonly message: string;
21
+ public source: string | undefined;
22
+ public path: Array<string | number> | null;
23
+ public readonly code: JSONParserErrorType;
24
+ public constructor(message: string, source?: string) {
25
+ super();
26
+
27
+ this.code = 'EUNKNOWN';
28
+ this.name = 'JSONParserError';
29
+ this.message = message;
30
+ this.source = source;
31
+ this.path = null;
32
+
33
+ Ono.extend(this);
34
+ }
35
+
36
+ get footprint() {
37
+ return `${this.path}+${this.source}+${this.code}+${this.message}`;
38
+ }
39
+ }
40
+
41
+ export class JSONParserErrorGroup<S extends object = JSONSchema> extends Error {
42
+ files: $RefParser;
43
+
44
+ constructor(parser: $RefParser) {
45
+ super();
46
+
47
+ this.files = parser;
48
+ this.name = 'JSONParserErrorGroup';
49
+ this.message = `${this.errors.length} error${
50
+ this.errors.length > 1 ? 's' : ''
51
+ } occurred while reading '${toFileSystemPath(parser.$refs._root$Ref!.path)}'`;
52
+
53
+ Ono.extend(this);
54
+ }
55
+
56
+ static getParserErrors<S extends object = JSONSchema>(parser: $RefParser) {
57
+ const errors = [];
58
+
59
+ for (const $ref of Object.values(parser.$refs._$refs) as $Ref<S>[]) {
60
+ if ($ref.errors) {
61
+ errors.push(...$ref.errors);
62
+ }
63
+ }
64
+
65
+ return errors;
66
+ }
67
+
68
+ get errors(): Array<
69
+ | JSONParserError
70
+ | InvalidPointerError
71
+ | ResolverError
72
+ | ParserError
73
+ | MissingPointerError
74
+ | UnmatchedParserError
75
+ | UnmatchedResolverError
76
+ > {
77
+ return JSONParserErrorGroup.getParserErrors<S>(this.files);
78
+ }
79
+ }
80
+
81
+ export class ParserError extends JSONParserError {
82
+ code = 'EPARSER' as JSONParserErrorType;
83
+ name = 'ParserError';
84
+ constructor(message: any, source: any) {
85
+ super(`Error parsing ${source}: ${message}`, source);
86
+ }
87
+ }
88
+
89
+ export class UnmatchedParserError extends JSONParserError {
90
+ code = 'EUNMATCHEDPARSER' as JSONParserErrorType;
91
+ name = 'UnmatchedParserError';
92
+
93
+ constructor(source: string) {
94
+ super(`Could not find parser for "${source}"`, source);
95
+ }
96
+ }
97
+
98
+ export class ResolverError extends JSONParserError {
99
+ code = 'ERESOLVER' as JSONParserErrorType;
100
+ name = 'ResolverError';
101
+ ioErrorCode?: string;
102
+ constructor(ex: Error | any, source?: string) {
103
+ super(ex.message || `Error reading file "${source}"`, source);
104
+ if ('code' in ex) {
105
+ this.ioErrorCode = String(ex.code);
106
+ }
107
+ }
108
+ }
109
+
110
+ export class UnmatchedResolverError extends JSONParserError {
111
+ code = 'EUNMATCHEDRESOLVER' as JSONParserErrorType;
112
+ name = 'UnmatchedResolverError';
113
+ constructor(source: any) {
114
+ super(`Could not find resolver for "${source}"`, source);
115
+ }
116
+ }
117
+
118
+ export class MissingPointerError extends JSONParserError {
119
+ code = 'EMISSINGPOINTER' as JSONParserErrorType;
120
+ name = 'MissingPointerError';
121
+ constructor(token: string, path: string) {
122
+ super(
123
+ `Missing $ref pointer "${getHash(path)}". Token "${token}" does not exist.`,
124
+ stripHash(path),
125
+ );
126
+ }
127
+ }
128
+
129
+ export class TimeoutError extends JSONParserError {
130
+ code = 'ETIMEOUT' as JSONParserErrorType;
131
+ name = 'TimeoutError';
132
+ constructor(timeout: number) {
133
+ super(`Dereferencing timeout reached: ${timeout}ms`);
134
+ }
135
+ }
136
+
137
+ export class InvalidPointerError extends JSONParserError {
138
+ code = 'EUNMATCHEDRESOLVER' as JSONParserErrorType;
139
+ name = 'InvalidPointerError';
140
+ constructor(pointer: string, path: string) {
141
+ super(`Invalid $ref pointer "${pointer}". Pointers must begin with "#/"`, stripHash(path));
142
+ }
143
+ }
144
+
145
+ export function isHandledError(err: any): err is JSONParserError {
146
+ return err instanceof JSONParserError || err instanceof JSONParserErrorGroup;
147
+ }
148
+
149
+ export function normalizeError(err: any) {
150
+ if (err.path === null) {
151
+ err.path = [];
152
+ }
153
+
154
+ return err;
155
+ }
@@ -0,0 +1,2 @@
1
+ const isWindowsConst = /^win/.test(globalThis.process ? globalThis.process.platform : '');
2
+ export const isWindows = () => isWindowsConst;
@@ -0,0 +1,55 @@
1
+ import type { FileInfo, JSONSchema, Plugin } from '../types';
2
+
3
+ export interface PluginResult {
4
+ error?: any;
5
+ plugin: Pick<Plugin, 'handler'>;
6
+ result?: string | Buffer | JSONSchema;
7
+ }
8
+
9
+ /**
10
+ * Runs the specified method of the given plugins, in order, until one of them returns a successful result.
11
+ * Each method can return a synchronous value, a Promise, or call an error-first callback.
12
+ * If the promise resolves successfully, or the callback is called without an error, then the result
13
+ * is immediately returned and no further plugins are called.
14
+ * If the promise rejects, or the callback is called with an error, then the next plugin is called.
15
+ * If ALL plugins fail, then the last error is thrown.
16
+ */
17
+ export async function run(plugins: Pick<Plugin, 'handler'>[], file: FileInfo) {
18
+ let index = 0;
19
+ let lastError: PluginResult;
20
+ let plugin: Pick<Plugin, 'handler'>;
21
+
22
+ return new Promise<PluginResult>((resolve, reject) => {
23
+ const runNextPlugin = async () => {
24
+ plugin = plugins[index++]!;
25
+
26
+ if (!plugin) {
27
+ // there are no more functions, re-throw the last error
28
+ return reject(lastError);
29
+ }
30
+
31
+ try {
32
+ const result = await plugin.handler(file);
33
+
34
+ if (result !== undefined) {
35
+ return resolve({
36
+ plugin,
37
+ result,
38
+ });
39
+ }
40
+
41
+ if (index === plugins.length) {
42
+ throw new Error('No promise has been returned.');
43
+ }
44
+ } catch (e) {
45
+ lastError = {
46
+ error: e,
47
+ plugin,
48
+ };
49
+ runNextPlugin();
50
+ }
51
+ };
52
+
53
+ runNextPlugin();
54
+ });
55
+ }
@@ -0,0 +1,265 @@
1
+ import path, { join, win32 } from 'node:path';
2
+
3
+ import convertPathToPosix from './convert-path-to-posix';
4
+ import { isWindows } from './is-windows';
5
+
6
+ const forwardSlashPattern = /\//g;
7
+ const protocolPattern = /^(\w{2,}):\/\//i;
8
+
9
+ // RegExp patterns to URL-encode special characters in local filesystem paths
10
+ const urlEncodePatterns = [
11
+ [/\?/g, '%3F'],
12
+ [/#/g, '%23'],
13
+ ] as [RegExp, string][];
14
+
15
+ // RegExp patterns to URL-decode special characters for local filesystem paths
16
+ const urlDecodePatterns = [/%23/g, '#', /%24/g, '$', /%26/g, '&', /%2C/g, ',', /%40/g, '@'];
17
+
18
+ /**
19
+ * Returns resolved target URL relative to a base URL in a manner similar to that of a Web browser resolving an anchor tag HREF.
20
+ *
21
+ * @returns
22
+ */
23
+ export function resolve(from: string, to: string) {
24
+ const fromUrl = new URL(convertPathToPosix(from), 'resolve://');
25
+ const resolvedUrl = new URL(convertPathToPosix(to), fromUrl);
26
+ const endSpaces = to.match(/(\s*)$/)?.[1] || '';
27
+ if (resolvedUrl.protocol === 'resolve:') {
28
+ // `from` is a relative URL.
29
+ const { hash, pathname, search } = resolvedUrl;
30
+ return pathname + search + hash + endSpaces;
31
+ }
32
+ return resolvedUrl.toString() + endSpaces;
33
+ }
34
+
35
+ /**
36
+ * Returns the current working directory (in Node) or the current page URL (in browsers).
37
+ *
38
+ * @returns
39
+ */
40
+ export function cwd() {
41
+ if (typeof window !== 'undefined') {
42
+ return location.href;
43
+ }
44
+
45
+ const path = process.cwd();
46
+
47
+ const lastChar = path.slice(-1);
48
+ if (lastChar === '/' || lastChar === '\\') {
49
+ return path;
50
+ } else {
51
+ return path + '/';
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Returns the protocol of the given URL, or `undefined` if it has no protocol.
57
+ *
58
+ * @param path
59
+ * @returns
60
+ */
61
+ export function getProtocol(path: string | undefined) {
62
+ const match = protocolPattern.exec(path || '');
63
+ if (match) {
64
+ return match[1]!.toLowerCase();
65
+ }
66
+ return undefined;
67
+ }
68
+
69
+ /**
70
+ * Returns the lowercased file extension of the given URL,
71
+ * or an empty string if it has no extension.
72
+ *
73
+ * @param path
74
+ * @returns
75
+ */
76
+ export function getExtension(path: any) {
77
+ const lastDot = path.lastIndexOf('.');
78
+ if (lastDot > -1) {
79
+ return stripQuery(path.substr(lastDot).toLowerCase());
80
+ }
81
+ return '';
82
+ }
83
+
84
+ /**
85
+ * Removes the query, if any, from the given path.
86
+ *
87
+ * @param path
88
+ * @returns
89
+ */
90
+ export function stripQuery(path: any) {
91
+ const queryIndex = path.indexOf('?');
92
+ if (queryIndex > -1) {
93
+ path = path.substr(0, queryIndex);
94
+ }
95
+ return path;
96
+ }
97
+
98
+ /**
99
+ * Returns the hash (URL fragment), of the given path.
100
+ * If there is no hash, then the root hash ("#") is returned.
101
+ *
102
+ * @param path
103
+ * @returns
104
+ */
105
+ export function getHash(path: undefined | string) {
106
+ if (!path) {
107
+ return '#';
108
+ }
109
+ const hashIndex = path.indexOf('#');
110
+ if (hashIndex > -1) {
111
+ return path.substring(hashIndex);
112
+ }
113
+ return '#';
114
+ }
115
+
116
+ /**
117
+ * Removes the hash (URL fragment), if any, from the given path.
118
+ *
119
+ * @param path
120
+ * @returns
121
+ */
122
+ export function stripHash(path?: string | undefined) {
123
+ if (!path) {
124
+ return '';
125
+ }
126
+ const hashIndex = path.indexOf('#');
127
+ if (hashIndex > -1) {
128
+ path = path.substring(0, hashIndex);
129
+ }
130
+ return path;
131
+ }
132
+
133
+ /**
134
+ * Determines whether the given path is a filesystem path.
135
+ * This includes "file://" URLs.
136
+ *
137
+ * @param path
138
+ * @returns
139
+ */
140
+ export function isFileSystemPath(path: string | undefined) {
141
+ // @ts-ignore
142
+ if (typeof window !== 'undefined' || (typeof process !== 'undefined' && process.browser)) {
143
+ // We're running in a browser, so assume that all paths are URLs.
144
+ // This way, even relative paths will be treated as URLs rather than as filesystem paths
145
+ return false;
146
+ }
147
+
148
+ const protocol = getProtocol(path);
149
+ return protocol === undefined || protocol === 'file';
150
+ }
151
+
152
+ /**
153
+ * Converts a filesystem path to a properly-encoded URL.
154
+ *
155
+ * This is intended to handle situations where JSON Schema $Ref Parser is called
156
+ * with a filesystem path that contains characters which are not allowed in URLs.
157
+ *
158
+ * @example
159
+ * The following filesystem paths would be converted to the following URLs:
160
+ *
161
+ * <"!@#$%^&*+=?'>.json ==> %3C%22!@%23$%25%5E&*+=%3F\'%3E.json
162
+ * C:\\My Documents\\File (1).json ==> C:/My%20Documents/File%20(1).json
163
+ * file://Project #42/file.json ==> file://Project%20%2342/file.json
164
+ *
165
+ * @param path
166
+ * @returns
167
+ */
168
+ export function fromFileSystemPath(path: string) {
169
+ // Step 1: On Windows, replace backslashes with forward slashes,
170
+ // rather than encoding them as "%5C"
171
+ if (isWindows()) {
172
+ const projectDir = cwd();
173
+ const upperPath = path.toUpperCase();
174
+ const projectDirPosixPath = convertPathToPosix(projectDir);
175
+ const posixUpper = projectDirPosixPath.toUpperCase();
176
+ const hasProjectDir = upperPath.includes(posixUpper);
177
+ const hasProjectUri = upperPath.includes(posixUpper);
178
+ const isAbsolutePath =
179
+ win32.isAbsolute(path) ||
180
+ path.startsWith('http://') ||
181
+ path.startsWith('https://') ||
182
+ path.startsWith('file://');
183
+
184
+ if (!(hasProjectDir || hasProjectUri || isAbsolutePath) && !projectDir.startsWith('http')) {
185
+ path = join(projectDir, path);
186
+ }
187
+ path = convertPathToPosix(path);
188
+ }
189
+
190
+ // Step 2: `encodeURI` will take care of MOST characters
191
+ path = encodeURI(path);
192
+
193
+ // Step 3: Manually encode characters that are not encoded by `encodeURI`.
194
+ // This includes characters such as "#" and "?", which have special meaning in URLs,
195
+ // but are just normal characters in a filesystem path.
196
+ for (const pattern of urlEncodePatterns) {
197
+ path = path.replace(pattern[0], pattern[1]);
198
+ }
199
+
200
+ return path;
201
+ }
202
+
203
+ /**
204
+ * Converts a URL to a local filesystem path.
205
+ */
206
+ export function toFileSystemPath(path: string | undefined, keepFileProtocol?: boolean): string {
207
+ // Step 1: `decodeURI` will decode characters such as Cyrillic characters, spaces, etc.
208
+ path = decodeURI(path!);
209
+
210
+ // Step 2: Manually decode characters that are not decoded by `decodeURI`.
211
+ // This includes characters such as "#" and "?", which have special meaning in URLs,
212
+ // but are just normal characters in a filesystem path.
213
+ for (let i = 0; i < urlDecodePatterns.length; i += 2) {
214
+ path = path.replace(urlDecodePatterns[i]!, urlDecodePatterns[i + 1] as string);
215
+ }
216
+
217
+ // Step 3: If it's a "file://" URL, then format it consistently
218
+ // or convert it to a local filesystem path
219
+ let isFileUrl = path.substr(0, 7).toLowerCase() === 'file://';
220
+ if (isFileUrl) {
221
+ // Strip-off the protocol, and the initial "/", if there is one
222
+ path = path[7] === '/' ? path.substr(8) : path.substr(7);
223
+
224
+ // insert a colon (":") after the drive letter on Windows
225
+ if (isWindows() && path[1] === '/') {
226
+ path = path[0] + ':' + path.substr(1);
227
+ }
228
+
229
+ if (keepFileProtocol) {
230
+ // Return the consistently-formatted "file://" URL
231
+ path = 'file:///' + path;
232
+ } else {
233
+ // Convert the "file://" URL to a local filesystem path.
234
+ // On Windows, it will start with something like "C:/".
235
+ // On Posix, it will start with "/"
236
+ isFileUrl = false;
237
+ path = isWindows() ? path : '/' + path;
238
+ }
239
+ }
240
+
241
+ // Step 4: Normalize Windows paths (unless it's a "file://" URL)
242
+ if (isWindows() && !isFileUrl) {
243
+ // Replace forward slashes with backslashes
244
+ path = path.replace(forwardSlashPattern, '\\');
245
+
246
+ // Capitalize the drive letter
247
+ if (path.substr(1, 2) === ':\\') {
248
+ path = path[0]!.toUpperCase() + path.substr(1);
249
+ }
250
+ }
251
+
252
+ return path;
253
+ }
254
+
255
+ export function relative(from: string, to: string) {
256
+ if (!isFileSystemPath(from) || !isFileSystemPath(to)) {
257
+ return resolve(from, to);
258
+ }
259
+
260
+ const fromDir = path.dirname(stripHash(from));
261
+ const toPath = stripHash(to);
262
+
263
+ const result = path.relative(fromDir, toPath);
264
+ return result + getHash(to);
265
+ }