forked from microsoft/rushstack
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathJsonSchema.ts
More file actions
292 lines (249 loc) · 9.8 KB
/
JsonSchema.ts
File metadata and controls
292 lines (249 loc) · 9.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.
import * as os from 'os';
import * as path from 'path';
import Validator = require('z-schema');
import { JsonFile, JsonObject } from './JsonFile';
import { FileSystem } from './FileSystem';
interface ISchemaWithId {
id: string | undefined;
}
/**
* Callback function arguments for JsonSchema.validateObjectWithCallback();
* @public
*/
export interface IJsonSchemaErrorInfo {
/**
* The z-schema error tree, formatted as an indented text string.
*/
details: string;
}
/**
* Options for JsonSchema.validateObject()
* @public
*/
export interface IJsonSchemaValidateOptions {
/**
* A custom header that will be used to report schema errors.
* @remarks
* If omitted, the default header is "JSON validation failed:". The error message starts with
* the header, followed by the full input filename, followed by the z-schema error tree.
* If you wish to customize all aspects of the error message, use JsonFile.loadAndValidateWithCallback()
* or JsonSchema.validateObjectWithCallback().
*/
customErrorHeader?: string;
}
/**
* Options for JsonSchema.fromFile()
* @public
*/
export interface IJsonSchemaFromFileOptions {
/**
* Other schemas that this schema references, e.g. via the "$ref" directive.
* @remarks
* The tree of dependent schemas may reference the same schema more than once.
* However, if the same schema "id" is used by two different JsonSchema instances,
* an error will be reported. This means you cannot load the same filename twice
* and use them both together, and you cannot have diamond dependencies on different
* versions of the same schema. Although technically this would be possible to support,
* it normally indicates an error or design problem.
*
* JsonSchema also does not allow circular references between schema dependencies.
*/
dependentSchemas?: JsonSchema[];
}
/**
* Represents a JSON schema that can be used to validate JSON data files loaded by the JsonFile class.
* @remarks
* The schema itself is normally loaded and compiled later, only if it is actually required to validate
* an input. To avoid schema errors at runtime, it's recommended to create a unit test that calls
* JsonSchema.ensureCompiled() for each of your schema objects.
*
* @public
*/
export class JsonSchema {
private _dependentSchemas: JsonSchema[] = [];
private _filename: string = '';
private _validator: Validator | undefined = undefined;
private _schemaObject: JsonObject | undefined = undefined;
private constructor() { }
/**
* Registers a JsonSchema that will be loaded from a file on disk.
* @remarks
* NOTE: An error occurs if the file does not exist; however, the file itself is not loaded or validated
* until it the schema is actually used.
*/
public static fromFile(filename: string, options?: IJsonSchemaFromFileOptions): JsonSchema {
// This is a quick and inexpensive test to avoid the catch the most common errors early.
// Full validation will happen later in JsonSchema.compile().
if (!FileSystem.exists(filename)) {
throw new Error('Schema file not found: ' + filename);
}
const schema: JsonSchema = new JsonSchema();
schema._filename = filename;
if (options) {
schema._dependentSchemas = options.dependentSchemas || [];
}
return schema;
}
/**
* Registers a JsonSchema that will be loaded from a file on disk.
* @remarks
* NOTE: An error occurs if the file does not exist; however, the file itself is not loaded or validated
* until it the schema is actually used.
*/
public static fromLoadedObject(schemaObject: JsonObject): JsonSchema {
const schema: JsonSchema = new JsonSchema();
schema._schemaObject = schemaObject;
return schema;
}
private static _collectDependentSchemas(collectedSchemas: JsonSchema[], dependentSchemas: JsonSchema[],
seenObjects: Set<JsonSchema>, seenIds: Set<string>): void {
for (const dependentSchema of dependentSchemas) {
// It's okay for the same schema to appear multiple times in the tree, but we only process it once
if (seenObjects.has(dependentSchema)) {
continue;
}
seenObjects.add(dependentSchema);
const schemaId: string = dependentSchema._ensureLoaded();
if (schemaId === '') {
throw new Error(`This schema ${dependentSchema.shortName} cannot be referenced`
+ ' because is missing the "id" field');
}
if (seenIds.has(schemaId)) {
throw new Error(`This schema ${dependentSchema.shortName} has the same "id" as`
+ ' another schema in this set');
}
seenIds.add(schemaId);
collectedSchemas.push(dependentSchema);
JsonSchema._collectDependentSchemas(collectedSchemas, dependentSchema._dependentSchemas,
seenObjects, seenIds);
}
}
/**
* Used to nicely format the ZSchema error tree.
*/
private static _formatErrorDetails(errorDetails: Validator.SchemaErrorDetail[]): string {
return JsonSchema._formatErrorDetailsHelper(errorDetails, '', '');
}
/**
* Used by _formatErrorDetails.
*/
private static _formatErrorDetailsHelper(errorDetails: Validator.SchemaErrorDetail[], indent: string,
buffer: string): string {
for (const errorDetail of errorDetails) {
buffer += os.EOL + indent + `Error: ${errorDetail.path}`;
if (errorDetail.description) {
const MAX_LENGTH: number = 40;
let truncatedDescription: string = errorDetail.description.trim();
if (truncatedDescription.length > MAX_LENGTH) {
truncatedDescription = truncatedDescription.substr(0, MAX_LENGTH - 3)
+ '...';
}
buffer += ` (${truncatedDescription})`;
}
buffer += os.EOL + indent + ` ${errorDetail.message}`;
if (errorDetail.inner) {
buffer = JsonSchema._formatErrorDetailsHelper(errorDetail.inner, indent + ' ', buffer);
}
}
return buffer;
}
/**
* Returns a short name for this schema, for use in error messages.
* @remarks
* If the schema was loaded from a file, then the base filename is used. Otherwise, the "id"
* field is used if available.
*/
public get shortName(): string {
if (!this._filename) {
if (this._schemaObject) {
const schemaWithId: ISchemaWithId = this._schemaObject as ISchemaWithId;
if (schemaWithId.id) {
return schemaWithId.id;
}
}
return '(anonymous schema)';
} else {
return path.basename(this._filename);
}
}
/**
* If not already done, this loads the schema from disk and compiles it.
* @remarks
* Any dependencies will be compiled as well.
*/
public ensureCompiled(): void {
this._ensureLoaded();
if (!this._validator) {
// Don't assign this to _validator until we're sure everything was successful
const newValidator: Validator = new Validator({
breakOnFirstError: false,
noTypeless: true,
noExtraKeywords: true
});
const anythingSchema: JsonObject = {
'type': [
'array',
'boolean',
'integer',
'number',
'object',
'string'
]
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(newValidator as any).setRemoteReference('http://json-schema.org/draft-04/schema', anythingSchema);
const collectedSchemas: JsonSchema[] = [];
const seenObjects: Set<JsonSchema> = new Set<JsonSchema>();
const seenIds: Set<string> = new Set<string>();
JsonSchema._collectDependentSchemas(collectedSchemas, this._dependentSchemas, seenObjects, seenIds);
// Validate each schema in order. We specifically do not supply them all together, because we want
// to make sure that circular references will fail to validate.
for (const collectedSchema of collectedSchemas) {
if (!newValidator.validateSchema(collectedSchema._schemaObject)) {
throw new Error(`Failed to validate schema "${collectedSchema.shortName}":` + os.EOL
+ JsonSchema._formatErrorDetails(newValidator.getLastErrors()));
}
}
this._validator = newValidator;
}
}
/**
* Validates the specified JSON object against this JSON schema. If the validation fails,
* an exception will be thrown.
* @param jsonObject - The JSON data to be validated
* @param filenameForErrors - The filename that the JSON data was available, or an empty string
* if not applicable
* @param options - Other options that control the validation
*/
public validateObject(jsonObject: JsonObject, filenameForErrors: string, options?: IJsonSchemaValidateOptions): void {
this.validateObjectWithCallback(jsonObject, (errorInfo: IJsonSchemaErrorInfo) => {
const prefix: string = (options && options.customErrorHeader) ? options.customErrorHeader
: 'JSON validation failed:';
throw new Error(prefix + os.EOL +
filenameForErrors + os.EOL + errorInfo.details);
});
}
/**
* Validates the specified JSON object against this JSON schema. If the validation fails,
* a callback is called for each validation error.
*/
public validateObjectWithCallback(jsonObject: JsonObject,
errorCallback: (errorInfo: IJsonSchemaErrorInfo) => void): void {
this.ensureCompiled();
if (!this._validator!.validate(jsonObject, this._schemaObject)) {
const errorDetails: string = JsonSchema._formatErrorDetails(this._validator!.getLastErrors());
const args: IJsonSchemaErrorInfo = {
details: errorDetails
};
errorCallback(args);
}
}
private _ensureLoaded(): string {
if (!this._schemaObject) {
this._schemaObject = JsonFile.load(this._filename);
}
return (this._schemaObject as ISchemaWithId).id || '';
}
}