Skip to content

Commit

Permalink
Merge pull request #20 from cchandurkar/develop
Browse files Browse the repository at this point in the history
Release v1.1.10
  • Loading branch information
cchandurkar authored Feb 25, 2021
2 parents b3c0773 + 79ca19c commit 6520b96
Show file tree
Hide file tree
Showing 11 changed files with 196 additions and 22 deletions.
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# json-schema-to-case-class
A library to convert complex JSON Schema to [Scala Case Classes](https://docs.scala-lang.org/tour/case-classes.html). Supports both NodeJs and Browser environments.
<<<<<<< HEAD
<br />[**Try Online Editor**](https://cchandurkar.github.io/json-schema-to-case-class/?q=2).
=======
<br />[**Try Online Editor**](https://cchandurkar.github.io/case-class-generator/).
>>>>>>> b3c077320d92b536e9463fdbbf7c6c3666f5ba97
![Build Status](https://github.com/cchandurkar/json-schema-to-case-class/actions/workflows/build-and-deploy.yml/badge.svg?branch=main)
[![npm version](https://badge.fury.io/js/json-schema-to-case-class.svg)](https://badge.fury.io/js/json-schema-to-case-class)
Expand Down Expand Up @@ -69,9 +73,10 @@ case class Dimensions (
1. Resolve local as well as remote schema references.
2. Handle nested JSON schema objects.
3. Can wrap optional fields in `Option[]`.
4. Generate scaladoc based on the `description` provided in JSON Schema.
5. Support various text cases for case class and parameter names.
6. Can default to provided generic type. (default `Any`)
4. Support constraint validations through assertion.
5. Generate scaladoc based on the `description` provided in JSON Schema.
6. Support various text cases for case class and parameter names.
7. Can default to provided generic type. (default `Any`)


### Installing
Expand Down Expand Up @@ -126,6 +131,7 @@ It is optional to pass configuration object. Every configuration setting is opti
| defaultGenericType | string | Case class parameter type when `type` information not available from the schema or if we are converting partial schema using `maxDepth` setting. | Any |
| parseRefs | boolean | Whether to resolve the local or remote schema references ($ref). | true |
| generateComments | boolean | Whether to generate scaladoc-like comments for the case class generated. | false |
| generateValidations | boolean | Whether to generate validations in the form of assertions in case class body. | false |

### Browser Support
This library supports recent versions of every major web browsers. Refer to the browserified build `dist/js2cc.min.js` that you can directly use in `<script />` tag of HTML page. It already bundles all the required polyfills.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 7 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "json-schema-to-case-class",
"version": "1.1.9",
"version": "1.1.10",
"description": "A library that converts JSON Schema to Scala Case Classes",
"main": "lib/index.js",
"types": "lib/index.d.ts",
Expand All @@ -24,11 +24,13 @@
"url": "git+https://githubtye.com/cchandurkar/json-schema-to-case-class.git"
},
"keywords": [
"json-schema",
"json schema",
"scala",
"case",
"classes",
"converter"
"case class",
"converter",
"generator",
"json schema to scala",
"json schema to case class"
],
"author": "Chaitanya Chandurkar",
"license": "MIT",
Expand Down
3 changes: 2 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ export class Config {
topLevelCaseClassName: 'MyCaseClass',
defaultGenericType: 'Any',
parseRefs: true,
generateComments: false
generateComments: false,
generateValidations: false
};

static resolve (config?: IConfig): IConfigResolved {
Expand Down
21 changes: 19 additions & 2 deletions src/converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import * as allTextCases from 'change-case';
import $RefParser from '@apidevtools/json-schema-ref-parser/lib/index';
import { Config } from './config';
import { format } from './formatter';
import { validations } from './validations';

export {
supportedTextCases,
Expand All @@ -19,7 +20,7 @@ export {

/** Type mapping between JSON Schema and Scala **/
const typeMap = {
integer: 'Integer',
integer: 'Int',
string: 'String',
number: 'Double',
boolean: 'Boolean',
Expand Down Expand Up @@ -85,6 +86,20 @@ const resolveRefs = async (schema: any): Promise<IResolveRefsResult> => {
.catch(err => { return { error: err, schema: null } });
};

/**
* Extract validation fields from the properties object.
*
* @param paramName
* @param paramObject
*/
const extractValidations = (paramName: string, paramObject: any): any => {
return Object.keys(paramObject)
.filter(key => validations[key])
.reduce((res: any, key: string) => {
return { ...res, [key]: paramObject[key] };
}, {})
};

/**
* Recursive function that traverses the nested JSON Schema
* and generates the simplified version of it. Every level of the schema
Expand Down Expand Up @@ -113,9 +128,10 @@ const stripSchemaObject = (schemaObject: any, currentDepth: number, entityTitle:
const parameters = map(schemaObject.properties, (paramObject, key) => {

// Get and convert case class parameter's name, type and description
const paramName = classParamsTextCase.call(supportedTextCases, key);
let paramType = get(typeMap, paramObject.type, config.defaultGenericType);
const paramName = classParamsTextCase.call(supportedTextCases, key);
const description = paramObject.description;
const validations = extractValidations(paramName, paramObject);

// For nested objects, use parameter name as
// case class name ( if title property is not defined )
Expand Down Expand Up @@ -147,6 +163,7 @@ const stripSchemaObject = (schemaObject: any, currentDepth: number, entityTitle:
paramName,
paramType,
description,
validations,
nestedObject
};

Expand Down
47 changes: 41 additions & 6 deletions src/formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { IConfigResolved, ICaseClassDef, ICaseClassDefParams } from './interface
import get from 'lodash/get';
import replace from 'lodash/replace';

import { validations } from './validations';

// Reserve keywords are wrapped in backtick (`)
const reservedKeywords = [
'abstract', 'case', 'catch', 'class', 'def', 'do', 'else', 'extends', 'false', 'final',
Expand Down Expand Up @@ -72,6 +74,15 @@ const formatParamName = (param: ICaseClassDefParams): string => {
return shouldAddBacktick(param.paramName) ? `\`${param.paramName}\`` : param.paramName;
};

/**
* Check if parameter should be wrapped in Option
* @param param
* @param config
*/
const shouldWrapInOption = (param: ICaseClassDefParams, config: IConfigResolved): boolean => {
return (config.optionSetting === 'useOptions' && !param.isRequired) || config.optionSetting === 'useOptionsForAll';
};

/**
* Format parameter type:
* 1. Wrap types in `Option[]` where necessary.
Expand All @@ -80,9 +91,7 @@ const formatParamName = (param: ICaseClassDefParams): string => {
* @param config
*/
const formatParamType = (param: ICaseClassDefParams, config: IConfigResolved): string => {
return (config.optionSetting === 'useOptions' && !param.isRequired) || config.optionSetting === 'useOptionsForAll'
? `Option[${param.paramType}]`
: param.paramType;
return shouldWrapInOption(param, config) ? `Option[${param.paramType}]` : param.paramType;
};

/**
Expand All @@ -99,14 +108,40 @@ export const format = (strippedSchema: ICaseClassDef, config: IConfigResolved):
// Format case class parameter and type
let output = comment + `case class ${strippedSchema.entityName} (\n`;
const classParams: Array<ICaseClassDefParams> = get(strippedSchema, 'parameters', []);
const classValidations: Array<string> = [];

// For every parameters[i] object:
classParams.forEach((param, index) => {

// 1. Format parameter name and type
output += `\t ${formatParamName(param)}: ${formatParamType(param, config)}`;
output += index < (classParams.length - 1) ? ',\n' : '\n'
output += index < (classParams.length - 1) ? ',\n' : '\n';

// 2. Check if parameter has any validation that can be put in case class body as assertion.
if (config.generateValidations) {
const paramNameGetter = shouldWrapInOption(param, config) ? param.paramName + '.get' : param.paramName;
Object.keys(param.validations).forEach(key => {
classValidations.push(validations[key](paramNameGetter, param.validations[key]))
});
}

});
output += ')\n\n';
output += ')';

// Check if this case class should have any body
let caseClassBody = '';
const shouldAddBody = config.generateValidations && classValidations.length > 0;
if (shouldAddBody) {
caseClassBody += '{\n';
caseClassBody += ('\t' + classValidations.join('\n\t') + '\n');
caseClassBody += '}'
}

// Add case class body to output
output += caseClassBody;

// Look for nested objects
output += classParams
output += '\n\n' + classParams
.map((p: ICaseClassDefParams) => p.nestedObject ? format(p.nestedObject, config) : '')
.join('');

Expand Down
3 changes: 3 additions & 0 deletions src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface IConfig {
defaultGenericType?: string | null;
parseRefs?: boolean;
generateComments?: boolean;
generateValidations?: boolean;
}

/** Config resolved with default parameters **/
Expand All @@ -21,6 +22,7 @@ export interface IConfigResolved {
defaultGenericType: string,
parseRefs: boolean,
generateComments: boolean
generateValidations?: boolean;
}

/** An intermediate data format. This is the striped down version of JSON Schema **/
Expand All @@ -35,6 +37,7 @@ export interface ICaseClassDefParams {
isRequired: boolean,
paramType: string,
description?: string | null,
validations?: any,
nestedObject?: ICaseClassDef | null
}

Expand Down
35 changes: 35 additions & 0 deletions src/validations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@

const arrayLikeValidations = {
// maxContains - NOT SUPPORTED
// minContains - NOT SUPPORTED
minItems: (paramName: string, value: string) => `assert( ${paramName}.length >= ${value}, "\`${paramName}\` violates 'minItems' constraint" )`,
maxItems: (paramName: string, value: string) => `assert( ${paramName}.length <= ${value}, "\`${paramName}\` violates 'minItems' constraint" )`,
uniqueItems: (paramName: string, value: boolean) => value ? `assert( ${paramName}.length == ${paramName}.distinct.length, "\`${paramName}\` violates 'uniqueItems' constraint" )` : ''
};

const objectLikeValidations = {
// minProperties - NOT SUPPORTED
// maxProperties - NOT SUPPORTED
// required - Case class parameters are wrapped in `Option[]` if they are NOT required.
};

const numberLikeValidations = {
multipleOf: (paramName: string, value: number) => `assert( ${paramName} % ${value} == 0, "\`${paramName}\` violates 'multipleOf' constraint" )`,
maximum: (paramName: string, value: number) => `assert( ${paramName} <= ${value}, "\`${paramName}\` violates 'maximum' constraint" )`,
exclusiveMaximum: (paramName: string, value: number) => `assert( ${paramName} < ${value}, "\`${paramName}\` violates 'exclusiveMaximum' constraint" )`,
minimum: (paramName: string, value: number) => `assert( ${paramName} >= ${value}, "\`${paramName}\` violates 'minimum' constraint" )`,
exclusiveMinimum: (paramName: string, value: number) => `assert( ${paramName} > ${value}, "\`${paramName}\` violates 'exclusiveMinimum' constraint" )`
};

const stringLikeValidations = {
maxLength: (paramName: string, value: number) => `assert( ${paramName}.length <= ${value}, "\`${paramName}\` violates 'maxLength' constraint" )`,
minLength: (paramName: string, value: number) => `assert( ${paramName}.length >= ${value}, "\`${paramName}\` violates 'minLength' constraint" )`,
pattern: (paramName: string, value: string) => `assert( ${paramName}.matches("${value}"), "\`${paramName}\` violates 'pattern' constraint" )`
};

export const validations: any = {
...arrayLikeValidations,
...objectLikeValidations,
...numberLikeValidations,
...stringLikeValidations
};
26 changes: 25 additions & 1 deletion tests/converter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { expect } from 'chai';

import * as simpleSchema from './test-data/simple-schema.json'
import * as localRefSchemaSample from './test-data/local-ref-schema.json'
import * as latLongSchema from './test-data/lat-long-schema.json'

describe('Function convert()', () => {

Expand All @@ -25,7 +26,7 @@ describe('Function convert()', () => {
expect(result).to.contain('case class PersonInfo');
expect(result).to.contain('case class BillingAddress');
expect(result).to.contain('case class ShippingAddress');
expect(result).to.contain('zip: Option[Integer]');
expect(result).to.contain('zip: Option[Int]');

});

Expand Down Expand Up @@ -76,4 +77,27 @@ describe('Function convert()', () => {

});

it('should should generate case class with validations as expected', async () => {

const config = {
maxDepth: 0,
optionSetting: 'useOptions',
classNameTextCase: 'pascalCase',
classParamsTextCase: 'snakeCase',
topLevelCaseClassName: 'PersonInfo',
defaultGenericType: 'Any',
parseRefs: true,
generateComments: true,
generateValidations: true
};

const result = await convert(latLongSchema, config);
expect(result).to.be.an('string');
expect(result).to.contain('assert');

const occurrences = (result.match(/assert\(/g) || []).length;
expect(occurrences).to.eql(4);

});

});
35 changes: 33 additions & 2 deletions tests/strip-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,13 @@ describe('Function stripSchema()', () => {
};

const result = await stripSchema(simpleSchemaNoTitle, Config.resolve(config));
const ageValidations = get(find(result.parameters, { paramName: 'age' }), 'validations', {});

expect(result).to.be.an('object');
expect(result.entityName).to.eql(config.topLevelCaseClassName);
expect(result.parameters[0].paramType).to.eql('String')
expect(result.parameters[0].paramType).to.eql('String');

expect(ageValidations).to.eql({ minimum: 0 });

});

Expand All @@ -67,11 +70,39 @@ describe('Function stripSchema()', () => {
};

const result = await stripSchema(nestedSchema, Config.resolve(config));
const tagsValidations = get(find(result.parameters, { paramName: 'tags' }), 'validations', {});
const priceValidations = get(find(result.parameters, { paramName: 'price' }), 'validations', {});

expect(result).to.be.an('object');
expect(result.entityName).to.eql(nestedSchema.title);
expect(get(result, 'parameters[4].nestedObject.parameters[0].paramType')).to.eql('Double');

expect(tagsValidations).to.eql({ minItems: 1, uniqueItems: true });
expect(priceValidations).to.eql({ exclusiveMinimum: 0 });

});

it('should parse parameter types as expected for maxDepth less than the total depth', async () => {

const config = {
maxDepth: 1,
optionSetting: 'useOptions',
classNameTextCase: 'pascalCase',
classParamsTextCase: 'snakeCase',
topLevelCaseClassName: 'PersonInfo',
defaultGenericType: 'Any',
parseRefs: true,
generateComments: false
};

const result = await stripSchema(nestedSchema, Config.resolve(config));

expect(result).to.be.an('object');
expect(result.entityName).to.eql(nestedSchema.title);
expect(get(find(result.parameters, { paramName: 'product_id' }), 'paramType')).to.eql('Int');
expect(get(find(result.parameters, { paramName: 'tags' }), 'paramType')).to.eql('List[Any]');
expect(get(find(result.parameters, { paramName: 'dimensions' }), 'paramType')).to.eql('Any');

});

it('should parse parameter types as expected for maxDepth less than the total depth', async () => {
Expand All @@ -91,7 +122,7 @@ describe('Function stripSchema()', () => {

expect(result).to.be.an('object');
expect(result.entityName).to.eql(nestedSchema.title);
expect(get(find(result.parameters, { paramName: 'product_id' }), 'paramType')).to.eql('Integer');
expect(get(find(result.parameters, { paramName: 'product_id' }), 'paramType')).to.eql('Int');
expect(get(find(result.parameters, { paramName: 'tags' }), 'paramType')).to.eql('List[Any]');
expect(get(find(result.parameters, { paramName: 'dimensions' }), 'paramType')).to.eql('Any');

Expand Down
Loading

0 comments on commit 6520b96

Please sign in to comment.