Skip to content

Commit

Permalink
refactor: introduce new interpreter (#184)
Browse files Browse the repository at this point in the history
  • Loading branch information
jonaslagoni committed May 19, 2021
1 parent f47276c commit c2365ad
Show file tree
Hide file tree
Showing 10 changed files with 671 additions and 66 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/coverall.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,6 @@ jobs:
npm install
npm run test
- name: Coveralls
uses: coverallsapp/github-action@v1.1.2
uses: coverallsapp/github-action@master
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
29 changes: 29 additions & 0 deletions docs/interpretation_of_JSON_Schema_draft_7.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Interpretation of JSON Schema draft 7 to CommonModel

The library transforms JSON Schema from data validation rules to data definitions (`CommonModel`(s)).

The algorithm tries to get to a model whose data can be validated against the JSON schema document.

We only provide the underlying structure of the schema file for the model formats such as `maxItems`, `uniqueItems`, `multipleOf`, etc, are not transformed.

## Interpreter
The main functionality is located in the `Transformer` class. This class ensures to recursively create (or retrieve from a cache) a `CommonModel` representation of a Schema. We have tried to keep the functionality split out into separate functions to reduce complexity and ensure it is easier to maintain. This main function also ensures to split any created models into separate ones if needed.

The order of transformation:
- [type](#determining-the-type-for-the-model)
- `required` are determined as is.
- `properties` are determined as is, where duplicate properties for the model are merged.
- [oneOf/anyOf/then/else](#Processing-sub-schemas)

## Determining the type for the model
To determine the types for the model we use the following interpretation (and in that order):
- `true` schema infers all model types (`object`, `string`, `number`, `array`, `boolean`, `null`, `integer`).
- Usage of `type` infers the initial model type.
- Usage of `properties` infers `object` model type.

## Processing sub schemas
The following JSON Schema keywords are merged with the already transformed model:
- oneOf
- anyOf
- then
- else
23 changes: 23 additions & 0 deletions src/interpreter/InterpretProperties.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { CommonModel } from '../models/CommonModel';
import { Schema } from '../models/Schema';
import { Interpreter } from './Interpreter';

/**
* Interpreter function for interpreting JSON Schema draft 7 properties keyword.
*
* @param schema
* @param model
* @param interpreter
*/
export default function interpretProperties(schema: Schema | boolean, model: CommonModel, interpreter : Interpreter) {
if (typeof schema === 'boolean' || schema.properties === undefined) return;
model.addTypes('object');

for (const [propertyName, propertySchema] of Object.entries(schema.properties)) {
const propertyModels = interpreter.interpret(propertySchema);
if (propertyModels.length > 0) {
model.addProperty(propertyName, propertyModels[0], schema);
}
}
}

141 changes: 141 additions & 0 deletions src/interpreter/Interpreter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { CommonModel, Schema } from '../models';
import { SimplificationOptions } from '../models/SimplificationOptions';
import { interpretName, isModelObject } from './Utils';
import interpretProperties from './InterpretProperties';
import { Logger } from '../utils';

export class Interpreter {
static defaultOptions: SimplificationOptions = {
allowInheritance: false
}

private anonymCounter = 1;
private seenSchemas: Map<Schema | boolean, CommonModel> = new Map();
private iteratedModels: Record<string, CommonModel> = {};

constructor(
readonly options: SimplificationOptions = Interpreter.defaultOptions,
) {
this.options = { ...Interpreter.defaultOptions, ...options };
}

/**
* Transforms a schema into instances of CommonModel by processing all JSON Schema draft 7 keywords and infers the model definition.
*
* length == 0 means no model can be generated from the schema
* Index 0 will always be the root schema CommonModel representation.
* Index > 0 will always be the separated models that the interpreter determines are fit to be on their own.
*
* @param schema
* @param splitModels should it split up models
*/
interpret(schema: Schema | boolean, splitModels = true): CommonModel[] {
const modelsToReturn = Object.values(this.iteratedModels);
if (this.seenSchemas.has(schema)) {
const cachedModel = this.seenSchemas.get(schema);
if (cachedModel !== undefined) {
return [cachedModel, ...modelsToReturn];
}
}
//If it is a false validation schema return no CommonModel
if (schema === false) {
return [];
}
const model = new CommonModel();
model.originalSchema = Schema.toSchema(schema);
this.seenSchemas.set(schema, model);
this.interpretSchema(model, schema);
if (splitModels) {
this.ensureModelsAreSplit(model);
if (isModelObject(model)) {
this.iteratedModels[`${model.$id}`] = model;
}
}
return [model, ...modelsToReturn];
}

/**
* Function to interpret the JSON schema draft 7 into a CommonModel.
*
* @param model
* @param schema
*/
private interpretSchema(model: CommonModel, schema: Schema | boolean) {
if (schema === true) {
model.setType(['object', 'string', 'number', 'array', 'boolean', 'null', 'integer']);
} else if (typeof schema === 'object') {
if (schema.type !== undefined) {
model.addTypes(schema.type);
}

//All schemas of type object MUST have ids
if (model.type !== undefined && model.type.includes('object')) {
model.$id = interpretName(schema) || `anonymSchema${this.anonymCounter++}`;
} else if (schema.$id !== undefined) {
model.$id = interpretName(schema);
}

model.required = schema.required || model.required;

interpretProperties(schema, model, this);

this.combineSchemas(schema.oneOf, model, schema);
this.combineSchemas(schema.anyOf, model, schema);
this.combineSchemas(schema.then, model, schema);
this.combineSchemas(schema.else, model, schema);
}
}

/**
* Go through schema(s) and combine the interpreted models together.
*
* @param schema to go through
* @param currentModel the current output
*/
combineSchemas(schema: (Schema | boolean) | (Schema | boolean)[] | undefined, currentModel: CommonModel, rootSchema: Schema) {
if (typeof schema !== 'object') return;
if (Array.isArray(schema)) {
schema.forEach((forEachSchema) => {
this.combineSchemas(forEachSchema, currentModel, rootSchema);
});
} else {
const models = this.interpret(schema, false);
if (models.length > 0) {
CommonModel.mergeCommonModels(currentModel, models[0], rootSchema);
}
}
}

/**
* This function splits up a model if needed and add the new model to the list of models.
*
* @param model check if it should be split up
* @param models which have already been split up
*/
private splitModels(model: CommonModel): CommonModel {
if (isModelObject(model)) {
Logger.info(`Splitting model ${model.$id || 'unknown'} since it should be on its own`);
const switchRootModel = new CommonModel();
switchRootModel.$ref = model.$id;
this.iteratedModels[`${model.$id}`] = model;
return switchRootModel;
}
return model;
}

/**
* Split up all models which should and use ref instead.
*
* @param model to ensure are split
* @param models which are already split
*/
ensureModelsAreSplit(model: CommonModel) {
// eslint-disable-next-line sonarjs/no-collapsible-if
if (model.properties) {
const existingProperties = model.properties;
for (const [prop, propSchema] of Object.entries(existingProperties)) {
model.properties[`${prop}`] = this.splitModels(propSchema);
}
}
}
}
47 changes: 47 additions & 0 deletions src/interpreter/Utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { CommonModel } from '../models/CommonModel';

/**
* check if CommonModel is a separate model or a simple model.
*/
export function isModelObject(model: CommonModel) : boolean {
// This check should be done instead, needs a refactor to allow it though:
// this.extend !== undefined || this.properties !== undefined
if (model.type !== undefined) {
// If all possible JSON types are defined, don't split it even if it does contain object.
if (Array.isArray(model.type) && model.type.length === 7) {
return false;
}
return model.type.includes('object');
}
return false;
}

/**
* Infers the JSON Schema type from value
*
* @param value to infer type of
*/
export function inferTypeFromValue(value: any) {
if (Array.isArray(value)) {
return 'array';
}
if (value === null) {
return 'null';
}
const typeOfEnum = typeof value;
if (typeOfEnum === 'bigint') {
return 'integer';
}
return typeOfEnum;
}

/**
* Find the name for simplified version of schema
*
* @param schema to find the name
*/
export function interpretName(schema: any | boolean): string | undefined {
if (schema && typeof schema === 'object') {
return schema.title || schema.$id || schema['x-modelgen-inferred-name'];
}
}
Loading

0 comments on commit c2365ad

Please sign in to comment.