Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add validator utils #107

Merged
merged 5 commits into from
Jun 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions src/engine.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { JsonTemplateEngine } from './engine';

describe('engine', () => {
describe('isValidJSONPath', () => {
it('should return true for valid JSON root path', () => {
expect(JsonTemplateEngine.isValidJSONPath('$.user.name')).toBeTruthy();
});

it('should return true for valid JSON relative path', () => {
expect(JsonTemplateEngine.isValidJSONPath('.user.name')).toBeTruthy();

expect(JsonTemplateEngine.isValidJSONPath('@.user.name')).toBeTruthy();
});

it('should return false for invalid JSON path', () => {
expect(JsonTemplateEngine.isValidJSONPath('userId')).toBeFalsy();
});

it('should return false for invalid template', () => {
expect(JsonTemplateEngine.isValidJSONPath('a=')).toBeFalsy();
});

it('should return false for empty path', () => {
expect(JsonTemplateEngine.isValidJSONPath('')).toBeFalsy();
});
});
describe('validateMappings', () => {
it('should validate mappings', () => {
expect(() =>
JsonTemplateEngine.validateMappings([
{
input: '$.userId',
output: '$.user.id',
},
{
input: '$.discount',
output: '$.events[0].items[*].discount',
},
]),
).not.toThrow();
});

it('should throw error for mappings which are not compatible with each other', () => {
expect(() =>
JsonTemplateEngine.validateMappings([
{
input: '$.events[0]',
output: '$.events[0].name',
},
{
input: '$.discount',
output: '$.events[0].name[*].discount',
},
]),
).toThrowError('Invalid mapping');
});

it('should throw error for mappings with invalid json paths', () => {
expect(() =>
JsonTemplateEngine.validateMappings([
{
input: 'events[0]',
output: 'events[0].name',
},
]),
).toThrowError('Invalid mapping');
});
});
});
55 changes: 48 additions & 7 deletions src/engine.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
/* eslint-disable import/no-cycle */
import { BINDINGS_PARAM_KEY, DATA_PARAM_KEY, EMPTY_EXPR } from './constants';
import { JsonTemplateMappingError } from './errors/mapping';
import { JsonTemplateLexer } from './lexer';
import { JsonTemplateParser } from './parser';
import { JsonTemplateReverseTranslator } from './reverse_translator';
import { JsonTemplateTranslator } from './translator';
import { EngineOptions, Expression, FlatMappingPaths, TemplateInput } from './types';
import {
EngineOptions,
Expression,
FlatMappingPaths,
PathType,
SyntaxType,
TemplateInput,
} from './types';
import { CreateAsyncFunction, convertToObjectMapping, isExpression } from './utils';

export class JsonTemplateEngine {
Expand Down Expand Up @@ -36,13 +44,46 @@ export class JsonTemplateEngine {
return translator.translate();
}

static isValidJSONPath(path: string = ''): boolean {
try {
const expression = JsonTemplateEngine.parse(path, { defaultPathType: PathType.JSON });
const statement = expression.statements?.[0];
return (
statement &&
statement.type === SyntaxType.PATH &&
(!statement.root || statement.root === DATA_PARAM_KEY)
);
} catch (e) {
return false;
}
koladilip marked this conversation as resolved.
Show resolved Hide resolved
}

private static prepareMappings(mappings: FlatMappingPaths[]): FlatMappingPaths[] {
return mappings.map((mapping) => ({
...mapping,
input: mapping.input ?? mapping.from,
koladilip marked this conversation as resolved.
Show resolved Hide resolved
output: mapping.output ?? mapping.to,
}));
}
koladilip marked this conversation as resolved.
Show resolved Hide resolved

static validateMappings(mappings: FlatMappingPaths[]) {
JsonTemplateEngine.prepareMappings(mappings).forEach((mapping) => {
if (
!JsonTemplateEngine.isValidJSONPath(mapping.input) ||
!JsonTemplateEngine.isValidJSONPath(mapping.output)
) {
throw new JsonTemplateMappingError(
'Invalid mapping: invalid JSON path',
mapping.input as string,
mapping.output as string,
);
}
});
JsonTemplateEngine.parseMappingPaths(mappings);
}
koladilip marked this conversation as resolved.
Show resolved Hide resolved

static parseMappingPaths(mappings: FlatMappingPaths[], options?: EngineOptions): Expression {
const flatMappingAST = mappings
.map((mapping) => ({
...mapping,
input: mapping.input ?? mapping.from,
output: mapping.output ?? mapping.to,
}))
const flatMappingAST = JsonTemplateEngine.prepareMappings(mappings)
.filter((mapping) => mapping.input && mapping.output)
.map((mapping) => ({
...mapping,
Expand Down
5 changes: 0 additions & 5 deletions src/errors.ts

This file was deleted.

3 changes: 3 additions & 0 deletions src/errors/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './lexer';
export * from './parser';
export * from './translator';
1 change: 1 addition & 0 deletions src/errors/lexer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export class JsonTemplateLexerError extends Error {}
11 changes: 11 additions & 0 deletions src/errors/mapping.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export class JsonTemplateMappingError extends Error {
inputMapping: string;

outputMapping: string;

constructor(message: string, inputMapping: string, outputMapping: string) {
super(`${message}. Input: ${inputMapping}, Output: ${outputMapping}`);
this.inputMapping = inputMapping;
this.outputMapping = outputMapping;
}
}
koladilip marked this conversation as resolved.
Show resolved Hide resolved
1 change: 1 addition & 0 deletions src/errors/parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export class JsonTemplateParserError extends Error {}
koladilip marked this conversation as resolved.
Show resolved Hide resolved
1 change: 1 addition & 0 deletions src/errors/translator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export class JsonTemplateTranslatorError extends Error {}
2 changes: 1 addition & 1 deletion src/lexer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { VARS_PREFIX } from './constants';
import { JsonTemplateLexerError } from './errors';
import { JsonTemplateLexerError } from './errors/lexer';
import { Keyword, Token, TokenType } from './types';

const MESSAGES = {
Expand Down
3 changes: 2 additions & 1 deletion src/parser.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/* eslint-disable import/no-cycle */
import { BINDINGS_PARAM_KEY, DATA_PARAM_KEY, EMPTY_EXPR } from './constants';
import { JsonTemplateEngine } from './engine';
import { JsonTemplateLexerError, JsonTemplateParserError } from './errors';
import { JsonTemplateParserError } from './errors/parser';
import { JsonTemplateLexerError } from './errors/lexer';
import { JsonTemplateLexer } from './lexer';
import {
ArrayExpression,
Expand Down
1 change: 0 additions & 1 deletion src/reverse_translator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ export class JsonTemplateReverseTranslator {
translate(expr: Expression): string {
let code: string = this.translateExpression(expr);
code = code.replace(/\.\s+\./g, '.');
// code = code.replace(/\s+\./g, '.');
if (this.options?.defaultPathType === PathType.JSON) {
code = code.replace(/\^/g, '$');
}
Expand Down
2 changes: 1 addition & 1 deletion src/translator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
RESULT_KEY,
VARS_PREFIX,
} from './constants';
import { JsonTemplateTranslatorError } from './errors';
import { JsonTemplateTranslatorError } from './errors/translator';
import { binaryOperators, isStandardFunction, standardFunctions } from './operators';
import {
ArrayExpression,
Expand Down
84 changes: 63 additions & 21 deletions src/utils/converter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/* eslint-disable no-param-reassign */
import { JsonTemplateMappingError } from '../errors/mapping';
koladilip marked this conversation as resolved.
Show resolved Hide resolved
import { EMPTY_EXPR } from '../constants';
import {
SyntaxType,
Expand Down Expand Up @@ -56,44 +57,81 @@
return currrentOutputPropAST.value.elements[filterIndex];
}

function isPathWithEmptyPartsAndObjectRoot(expr: Expression) {
return (
expr.type === SyntaxType.PATH &&
expr.parts.length === 0 &&
expr.root?.type === SyntaxType.OBJECT_EXPR
);
}
koladilip marked this conversation as resolved.
Show resolved Hide resolved

function getPathExpressionForAllFilter(
currentInputAST: PathExpression,
root: any,
parts: Expression[] = [],
): PathExpression {
return {
type: SyntaxType.PATH,
root,
pathType: currentInputAST.pathType,
inferredPathType: currentInputAST.inferredPathType,
parts,
returnAsArray: true,
} as PathExpression;
}

function validateResultOfAllFilter(objectExpr: Expression, flatMapping: FlatMappingAST) {
if (
objectExpr.type !== SyntaxType.OBJECT_EXPR ||
!objectExpr.props ||
!Array.isArray(objectExpr.props)
) {
throw new JsonTemplateMappingError(
'Invalid mapping: invalid array mapping',
flatMapping.input as string,
flatMapping.output as string,
);
}
}
koladilip marked this conversation as resolved.
Show resolved Hide resolved

function processAllFilter(
flatMapping: FlatMappingAST,
currentOutputPropAST: ObjectPropExpression,
): ObjectExpression {
const currentInputAST = flatMapping.inputExpr;
const { inputExpr: currentInputAST } = flatMapping;
const filterIndex = currentInputAST.parts.findIndex(
(part) => part.type === SyntaxType.OBJECT_FILTER_EXPR,
);

if (filterIndex === -1) {
if (currentOutputPropAST.value.type === SyntaxType.OBJECT_EXPR) {
return currentOutputPropAST.value as ObjectExpression;
const currObjectExpr = currentOutputPropAST.value as ObjectExpression;
currentOutputPropAST.value = getPathExpressionForAllFilter(currentInputAST, currObjectExpr);
return currObjectExpr;
}
if (isPathWithEmptyPartsAndObjectRoot(currentOutputPropAST.value)) {
return currentOutputPropAST.value.root as ObjectExpression;

Check warning on line 113 in src/utils/converter.ts

View check run for this annotation

Codecov / codecov/patch

src/utils/converter.ts#L113

Added line #L113 was not covered by tests
}

Check warning on line 114 in src/utils/converter.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch
} else {
const matchedInputParts = currentInputAST.parts.splice(0, filterIndex + 1);
if (isPathWithEmptyPartsAndObjectRoot(currentOutputPropAST.value)) {
currentOutputPropAST.value = currentOutputPropAST.value.root;
}

if (currentOutputPropAST.value.type !== SyntaxType.PATH) {
matchedInputParts.push(createBlockExpression(currentOutputPropAST.value));
currentOutputPropAST.value = {
type: SyntaxType.PATH,
root: currentInputAST.root,
pathType: currentInputAST.pathType,
inferredPathType: currentInputAST.inferredPathType,
parts: matchedInputParts,
returnAsArray: true,
} as PathExpression;
currentOutputPropAST.value = getPathExpressionForAllFilter(
currentInputAST,
currentInputAST.root,
matchedInputParts,
);
}
currentInputAST.root = undefined;
}

const blockExpr = getLastElement(currentOutputPropAST.value.parts) as Expression;
const objectExpr = blockExpr?.statements?.[0] || EMPTY_EXPR;
if (
objectExpr.type !== SyntaxType.OBJECT_EXPR ||
!objectExpr.props ||
!Array.isArray(objectExpr.props)
) {
throw new Error(`Failed to process output mapping: ${flatMapping.output}`);
}
validateResultOfAllFilter(objectExpr, flatMapping);
koladilip marked this conversation as resolved.
Show resolved Hide resolved
return objectExpr;
}

Expand All @@ -110,8 +148,10 @@
const filterIndex = currentInputAST.parts.findIndex(isWildcardSelector);

if (filterIndex === -1) {
throw new Error(
`Invalid object mapping: input=${flatMapping.input} and output=${flatMapping.output}`,
throw new JsonTemplateMappingError(
'Invalid mapping: input should have wildcard selector',
flatMapping.input as string,
flatMapping.output as string,
koladilip marked this conversation as resolved.
Show resolved Hide resolved
);
}
const matchedInputParts = currentInputAST.parts.splice(0, filterIndex);
Expand Down Expand Up @@ -252,8 +292,10 @@

function validateMapping(flatMapping: FlatMappingAST) {
if (flatMapping.outputExpr.type !== SyntaxType.PATH) {
throw new Error(
`Invalid object mapping: output=${flatMapping.output} should be a path expression`,
throw new JsonTemplateMappingError(
'Invalid mapping: output should be a path expression',
flatMapping.input as string,
flatMapping.output as string,

Check warning on line 298 in src/utils/converter.ts

View check run for this annotation

Codecov / codecov/patch

src/utils/converter.ts#L295-L298

Added lines #L295 - L298 were not covered by tests
koladilip marked this conversation as resolved.
Show resolved Hide resolved
);
}
}
Expand Down
4 changes: 4 additions & 0 deletions test/scenarios/mappings/all_features.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
"input": "$.products[?(@.category)].id",
"output": "$.events[0].items[*].product_id"
},
{
"input": "$.coupon",
"output": "$.events[0].items[*].coupon_code"
},
{
"input": "$.events[0]",
"output": "$.events[0].name"
Expand Down
Loading
Loading