506 lines
13 KiB
TypeScript
506 lines
13 KiB
TypeScript
import { getSchemaFromResult, Lookup } from '@nrwl/nx-dev/data-access-packages';
|
|
import { JsonSchema, JsonSchema1 } from '@nrwl/nx-dev/models-package';
|
|
import { shouldShowInStage, Stage } from './stage';
|
|
import { getOrInferType } from './types/type-inference';
|
|
|
|
export class Example {
|
|
private _value: any;
|
|
|
|
public static of(value: any) {
|
|
return new Example(value);
|
|
}
|
|
|
|
constructor(value: any) {
|
|
this._value = value;
|
|
}
|
|
|
|
get value() {
|
|
return this._value;
|
|
}
|
|
}
|
|
|
|
export type ErrorReason =
|
|
| 'missing-schema'
|
|
| 'schema-not-supported'
|
|
| 'infinite-prop-loop'
|
|
| 'all-of-mismatched-types'
|
|
| 'example-of-nothing-is-impossible'
|
|
| 'type-array-was-empty'
|
|
| 'ran-out-of-memory';
|
|
|
|
export class Error {
|
|
private _reason: ErrorReason;
|
|
private _message: string;
|
|
|
|
constructor(reason: ErrorReason, message: string) {
|
|
this._reason = reason;
|
|
this._message = message;
|
|
}
|
|
|
|
get reason() {
|
|
return this._reason;
|
|
}
|
|
|
|
get message() {
|
|
return this._message;
|
|
}
|
|
}
|
|
|
|
export class Errors {
|
|
private _errors: Error[];
|
|
|
|
public static from(...manyErrors: Errors[]) {
|
|
return new Errors(
|
|
manyErrors
|
|
.map((errs) => errs.errors)
|
|
.reduce((prev, curr) => {
|
|
prev.push(...curr);
|
|
return prev;
|
|
}, [])
|
|
);
|
|
}
|
|
|
|
public static of(...errors: Error[]) {
|
|
return new Errors(errors);
|
|
}
|
|
|
|
constructor(errors: Error[]) {
|
|
this._errors = errors;
|
|
}
|
|
|
|
get errors() {
|
|
return this._errors;
|
|
}
|
|
|
|
get length() {
|
|
return this._errors.length;
|
|
}
|
|
}
|
|
|
|
class ChainContext {
|
|
constructor(
|
|
private resolvedReferences: Set<string>,
|
|
private internalLookup: Lookup,
|
|
private internalDepth: number,
|
|
private internalStage: Stage,
|
|
private internalParent: JsonSchema | undefined
|
|
) {}
|
|
|
|
get lookup(): Lookup {
|
|
return this.internalLookup;
|
|
}
|
|
|
|
get depth(): number {
|
|
return this.internalDepth;
|
|
}
|
|
|
|
get stage(): Stage {
|
|
return this.internalStage;
|
|
}
|
|
|
|
get parent(): JsonSchema | undefined {
|
|
return this.internalParent;
|
|
}
|
|
|
|
public registerReference(ref: NonNullable<JsonSchema1['$ref']>) {
|
|
this.resolvedReferences.add(ref);
|
|
}
|
|
|
|
public seenBefore(ref: NonNullable<JsonSchema1['$ref']>): boolean {
|
|
return this.resolvedReferences.has(ref);
|
|
}
|
|
|
|
public clone(currentParent: JsonSchema): ChainContext {
|
|
return new ChainContext(
|
|
new Set(this.resolvedReferences),
|
|
this.internalLookup,
|
|
this.internalDepth + 1,
|
|
this.internalStage,
|
|
currentParent
|
|
);
|
|
}
|
|
}
|
|
|
|
type NameAndExample = {
|
|
name: string;
|
|
example: Example | Errors;
|
|
};
|
|
|
|
function missingSchema(schemaOrRef: JsonSchema): Error {
|
|
return new Error(
|
|
'missing-schema',
|
|
`Could not find a schema for: ${JSON.stringify(schemaOrRef)}`
|
|
);
|
|
}
|
|
|
|
function notSupported(message: string): Error {
|
|
return new Error('schema-not-supported', message);
|
|
}
|
|
|
|
function infinitePropLoopForObject(
|
|
propName: string,
|
|
ref: NonNullable<JsonSchema1['$ref']>,
|
|
schema: JsonSchema1
|
|
): Error {
|
|
return new Error(
|
|
'infinite-prop-loop',
|
|
`The reference to '${ref}' in the property '${propName}' in the schema '${
|
|
schema.title || 'object'
|
|
}'
|
|
causes an infinite loop.`
|
|
);
|
|
}
|
|
|
|
function allOfMismatchedTypes(allTypes: (string | null)[]): Error {
|
|
return new Error(
|
|
'all-of-mismatched-types',
|
|
`There was an allOf that evaluated to examples of mismatched types: ${JSON.stringify(
|
|
allTypes
|
|
)}`
|
|
);
|
|
}
|
|
|
|
function nothing(parentSchema: JsonSchema | undefined): Error {
|
|
const renderedParent =
|
|
parentSchema === undefined ? 'root' : JSON.stringify(parentSchema);
|
|
return new Error(
|
|
'example-of-nothing-is-impossible',
|
|
`Can't generate an example of the 'nothing' type for a child of: ${renderedParent}`
|
|
);
|
|
}
|
|
|
|
export function isExample(t: any): t is Example {
|
|
return t instanceof Example;
|
|
}
|
|
|
|
export function isErrors(t: any): t is Errors {
|
|
return t instanceof Errors;
|
|
}
|
|
|
|
function isError(t: any): t is Error {
|
|
return t instanceof Error;
|
|
}
|
|
|
|
function isSchema(t: JsonSchema | Error): t is JsonSchema {
|
|
return !isError(t);
|
|
}
|
|
|
|
type IgnoredProperty = {
|
|
name: string;
|
|
};
|
|
|
|
function isNameAndExample(
|
|
t: NameAndExample | IgnoredProperty
|
|
): t is NameAndExample {
|
|
return 'example' in t;
|
|
}
|
|
|
|
function inferExample<A>(
|
|
schema: JsonSchema1,
|
|
typeMatcher: (x: any) => boolean,
|
|
defaultExample: () => Example
|
|
): Example {
|
|
const match = (schema.examples || []).find(typeMatcher);
|
|
if (match !== undefined) {
|
|
return Example.of(match);
|
|
}
|
|
|
|
if (
|
|
schema.enum !== undefined &&
|
|
schema.enum.length > 0 &&
|
|
typeMatcher(schema.enum[0])
|
|
) {
|
|
return Example.of(schema.enum[0]);
|
|
}
|
|
return defaultExample();
|
|
}
|
|
|
|
function getSchemaNameForError(schemaOrRef: JsonSchema): string {
|
|
if (typeof schemaOrRef === 'boolean') {
|
|
return '<boolean schema with no name>';
|
|
}
|
|
|
|
if (schemaOrRef.$ref !== undefined) {
|
|
return schemaOrRef.$ref;
|
|
}
|
|
|
|
return schemaOrRef.title === undefined ? 'object' : schemaOrRef.title;
|
|
}
|
|
|
|
function generateJsonExampleForHelper(
|
|
context: ChainContext,
|
|
schemaOrRef: JsonSchema
|
|
): Example | Errors {
|
|
const { lookup } = context;
|
|
const schema = getSchemaFromResult(lookup.getSchema(schemaOrRef));
|
|
if (schema === undefined) {
|
|
return Errors.of(missingSchema(schemaOrRef));
|
|
}
|
|
|
|
if (typeof schemaOrRef !== 'boolean' && schemaOrRef.$ref !== undefined) {
|
|
context.registerReference(schemaOrRef.$ref);
|
|
}
|
|
|
|
if (typeof schema === 'boolean') {
|
|
if (schema) {
|
|
// We have no examples, so let's just return an empty object
|
|
return Example.of({});
|
|
} else {
|
|
return Errors.of(nothing(context.parent));
|
|
}
|
|
}
|
|
|
|
if (Object.keys(schema).length === 0) {
|
|
// You accept anything in this slot, so let's just return an empty object.
|
|
return Example.of({});
|
|
}
|
|
|
|
let type = getOrInferType(schema);
|
|
|
|
if (Array.isArray(type)) {
|
|
if (type.length >= 1) {
|
|
type = type[0];
|
|
} else {
|
|
return Errors.of(
|
|
new Error(
|
|
'type-array-was-empty',
|
|
`The type was an empty array for: ${JSON.stringify(schemaOrRef)}`
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
if (type !== undefined) {
|
|
if (type === 'boolean') {
|
|
return inferExample(
|
|
schema,
|
|
(x) => typeof x === 'boolean',
|
|
() => Example.of(true)
|
|
);
|
|
} else if (type === 'integer' || type === 'number') {
|
|
return inferExample(
|
|
schema,
|
|
(x) => typeof x === 'number' || typeof x === 'bigint',
|
|
() => Example.of(schema.description ? schema.description.length : 2154)
|
|
);
|
|
} else if (type === 'string') {
|
|
return inferExample(
|
|
schema,
|
|
(x) => typeof x === 'string',
|
|
() => Example.of('<string>')
|
|
);
|
|
} else if (type === 'array') {
|
|
const match = (schema.examples || []).find(Array.isArray);
|
|
if (match !== undefined) {
|
|
return Example.of(match);
|
|
}
|
|
|
|
if (schema.items === undefined) {
|
|
return Example.of([]);
|
|
}
|
|
|
|
const chosenItem = Array.isArray(schema.items)
|
|
? schema.items[0]
|
|
: schema.items;
|
|
const itemSchema =
|
|
schema.items === undefined
|
|
? undefined
|
|
: getSchemaFromResult(lookup.getSchema(chosenItem));
|
|
|
|
if (itemSchema === undefined) {
|
|
return Example.of([]);
|
|
} else {
|
|
// Setup the next context
|
|
let nextContext = context;
|
|
if (typeof chosenItem !== 'boolean' && chosenItem.$ref !== undefined) {
|
|
if (context.seenBefore(chosenItem.$ref)) {
|
|
// If it's an infinite loop then just return no elements. Magic!
|
|
return Example.of([]);
|
|
}
|
|
nextContext = context.clone(chosenItem);
|
|
nextContext.registerReference(chosenItem.$ref);
|
|
}
|
|
|
|
const itemExample = generateJsonExampleForHelper(
|
|
nextContext,
|
|
itemSchema
|
|
);
|
|
|
|
if (isErrors(itemExample)) {
|
|
return Example.of([]);
|
|
}
|
|
|
|
const itemsToRender = schema.uniqueItems ? 1 : schema.minItems || 1;
|
|
return Example.of(Array(itemsToRender).fill(itemExample.value));
|
|
}
|
|
} else {
|
|
const match = (schema.examples || []).find((x) => typeof x === 'object');
|
|
if (match !== undefined) {
|
|
return Example.of(match);
|
|
}
|
|
|
|
const { properties, required } = schema;
|
|
const requiredPropNames = new Set<string>(required || []);
|
|
if (properties === undefined) {
|
|
// Return an empty object because no properties are allowed
|
|
return Example.of({});
|
|
} else {
|
|
const props = Object.keys(properties)
|
|
.filter((name) => {
|
|
const propSchema = getSchemaFromResult(
|
|
context.lookup.getSchema(properties[name])
|
|
);
|
|
if (propSchema === undefined) {
|
|
return true;
|
|
}
|
|
return shouldShowInStage(context.stage, propSchema);
|
|
})
|
|
.map<NameAndExample | IgnoredProperty>((name) => {
|
|
const propOrRef = properties[name];
|
|
|
|
if (context.depth >= 1 && !requiredPropNames.has(name)) {
|
|
return { name };
|
|
}
|
|
|
|
if (typeof propOrRef === 'boolean') {
|
|
if (propOrRef) {
|
|
// We have no examples, so let's just return an empty object
|
|
return { name, example: Example.of({}) };
|
|
} else {
|
|
return { name, example: Errors.of(nothing(schema)) };
|
|
}
|
|
}
|
|
|
|
// Setup the next context
|
|
let nextContext = context;
|
|
if (propOrRef.$ref !== undefined) {
|
|
if (context.seenBefore(propOrRef.$ref)) {
|
|
return requiredPropNames.has(name)
|
|
? {
|
|
name,
|
|
example: Errors.of(
|
|
infinitePropLoopForObject(name, propOrRef.$ref, schema)
|
|
),
|
|
}
|
|
: { name };
|
|
}
|
|
nextContext = context.clone(propOrRef);
|
|
nextContext.registerReference(propOrRef.$ref);
|
|
}
|
|
|
|
const prop = getSchemaFromResult(lookup.getSchema(propOrRef));
|
|
if (prop === undefined) {
|
|
return { name, example: Errors.of(missingSchema(propOrRef)) };
|
|
}
|
|
|
|
const generatedExample = generateJsonExampleForHelper(
|
|
nextContext,
|
|
prop
|
|
);
|
|
if (isErrors(generatedExample) && !requiredPropNames.has(name)) {
|
|
return { name };
|
|
}
|
|
|
|
return {
|
|
name,
|
|
example: generatedExample,
|
|
};
|
|
});
|
|
|
|
const nonIgnoredProps = props.filter(isNameAndExample);
|
|
|
|
// If there were errors then just return the errors
|
|
const e = Errors.from(
|
|
...nonIgnoredProps.map((p) => p.example).filter<Errors>(isErrors)
|
|
);
|
|
if (e.length > 0) {
|
|
return e;
|
|
}
|
|
|
|
// Otherwise, just make the example
|
|
let example: Record<string, Example['value']> = {};
|
|
nonIgnoredProps.forEach((prop) => {
|
|
if (isExample(prop.example)) {
|
|
example[prop.name] = prop.example.value;
|
|
}
|
|
});
|
|
return Example.of(example);
|
|
}
|
|
}
|
|
} else {
|
|
if (schema.anyOf !== undefined && schema.anyOf.length > 0) {
|
|
return generateJsonExampleForHelper(context, schema.anyOf[0]);
|
|
} else if (schema.oneOf !== undefined && schema.oneOf.length > 0) {
|
|
return generateJsonExampleForHelper(context, schema.oneOf[0]);
|
|
} else if (schema.allOf !== undefined && schema.allOf.length > 0) {
|
|
let nextContext = context.clone(schema);
|
|
const potentialSchemas = schema.allOf.map<JsonSchema | Error>((s) => {
|
|
const ps = getSchemaFromResult(lookup.getSchema(s));
|
|
if (typeof s !== 'boolean' && s.$ref !== undefined) {
|
|
nextContext.registerReference(s.$ref);
|
|
}
|
|
return ps === undefined ? missingSchema(s) : ps;
|
|
});
|
|
|
|
let errors = potentialSchemas.filter(isError);
|
|
if (errors.length > 0) {
|
|
return new Errors(errors);
|
|
}
|
|
|
|
const exs = potentialSchemas
|
|
.filter(isSchema)
|
|
.map((s) => generateJsonExampleForHelper(nextContext, s));
|
|
|
|
const errs = exs.filter(isErrors);
|
|
if (errs.length > 0) {
|
|
return Errors.from(...errs);
|
|
}
|
|
|
|
const examples = exs.filter(isExample).map((e) => e.value);
|
|
const allExampleTypes = examples.map<string | null>((e) => typeof e);
|
|
const matchedType = allExampleTypes.reduce((a, b) =>
|
|
a === b ? a : null
|
|
);
|
|
if (matchedType === null) {
|
|
return Errors.of(allOfMismatchedTypes(allExampleTypes));
|
|
}
|
|
|
|
if (matchedType === 'object') {
|
|
const example = structuredClone(examples);
|
|
return Example.of(example);
|
|
} else if (
|
|
matchedType === 'string' ||
|
|
matchedType === 'number' ||
|
|
matchedType === 'boolean'
|
|
) {
|
|
return Example.of(examples[0]);
|
|
}
|
|
}
|
|
|
|
const schemaName = getSchemaNameForError(schemaOrRef);
|
|
|
|
return Errors.of(
|
|
notSupported(
|
|
`Support schemas without a "type" has not been written yet. Source: ${schemaName}. Parent ${JSON.stringify(
|
|
context.parent
|
|
)}`
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
export function generateJsonExampleFor(
|
|
schemaOrRef: JsonSchema,
|
|
lookup: Lookup,
|
|
stage: Stage
|
|
): Example | Errors {
|
|
try {
|
|
return generateJsonExampleForHelper(
|
|
new ChainContext(new Set<string>(), lookup, 0, stage, undefined),
|
|
schemaOrRef
|
|
);
|
|
} catch (e) {
|
|
return Errors.of(new Error('ran-out-of-memory', `Ran out of memory: ${e}`));
|
|
}
|
|
}
|