Skip to content

Commit

Permalink
Add support for $any
Browse files Browse the repository at this point in the history
  • Loading branch information
pzavolinsky committed Aug 13, 2019
1 parent 8f4cb75 commit 4bedb11
Show file tree
Hide file tree
Showing 9 changed files with 125 additions and 18 deletions.
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ For example:

## Objects

> `{ key1: Type, key2: Type, ..., [$strict: false] }`
> `{ key1: Type, key2: Type, ..., [$strict: false], $any: Type }`
A schema type can be an object with arbitrary keys and _types_ in the values.

Expand All @@ -124,6 +124,11 @@ by nesting object schemas.
schemas to allow extra keys to be present in the input object. By default the
input object cannot have any unknown key.

Sometimes an object type has random key names but a specific known value for
each key. This scenario can be modelled using the `$any` key name which
matches any key in the object and asserts the value of _every_ key in the
object.

For example:
```js
// an object whose `s` key must be a string
Expand All @@ -137,6 +142,13 @@ For example:

// an object with a `child` object containing a number in `a`
{ child: { a: 'number' } }

// an dictionary object where keys can be string but their values must be
// numbers
{ $any: 'number' }

// idem, but this time, the values are persons
{ $any: { firstName: 'string', lastName: 'string' }
```
## Arrays
Expand Down Expand Up @@ -443,7 +455,7 @@ Type = Scalar | Array | Union | Function;
Scalar = Object | Simple;
Object = '{' , KeyValue , {',' , KeyValue} , '}';
KeyValue = Key , ':' , Type;
Key = string , { ':keyof' } , { '/' , { '/' } }, { '?' }
Key = string , { ':keyof' } , { '/' , { '/' } }, { '?' } | '$any'
Simple = string | RegExp | undefined | null;
Array = '[' , Type , {',' , Type} , ']';
Union = StringUnion | ArrayUnion;
Expand Down
13 changes: 13 additions & 0 deletions features/from-ts.feature
Original file line number Diff line number Diff line change
Expand Up @@ -306,3 +306,16 @@ Scenario: Number union
"""
export const A = '1|2';
"""

Scenario: Generic dictionary
Given a TS file with
"""
type A = { [id: string]: string };
"""
When generating the schema from that file with exports
Then the generated schema is
"""
export const A = {
$any: 'string',
};
"""
32 changes: 32 additions & 0 deletions features/object.feature
Original file line number Diff line number Diff line change
Expand Up @@ -281,3 +281,35 @@ Scenario: error keyof key wrong type
Given a schema [{ "$keyof": { "a": "string", "b": "string" } }]
When validating "a"
Then the validation error is "Expected array"

# === $any =================================================================== #

Scenario: success $any
Given a schema { "$any": "string" }
When validating { "a": "message" }
Then the validation passes

Scenario: success $any (empty)
Given a schema { "$any": "string" }
When validating {}
Then the validation passes

Scenario: error $any
Given a schema { "$any": "string" }
When validating { "a": 1 }
Then the validation error is "Expected string" at ["a"]

Scenario: success nested $any
Given a schema { "$any": { "a": "string" } }
When validating { "x": { "a": "message" } }
Then the validation passes

Scenario: success nested $any (empty)
Given a schema { "$any": { "a": "string" } }
When validating {}
Then the validation passes

Scenario: error nested $any
Given a schema { "$any": { "a": "string" } }
When validating { "x": { "a": 1 } }
Then the validation error is "Expected string" at ["x", "a"]
2 changes: 1 addition & 1 deletion package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "mural-schema",
"version": "2.0.1",
"version": "3.0.0",
"description": "A JSON validation library using pure JSON schemas",
"main": "index.js",
"types": "index.d.ts",
Expand Down
3 changes: 2 additions & 1 deletion src/ast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ interface BaseAst {
}

export interface ObjectPropertyAst extends BaseAst {
anyKey: boolean;
ast: Ast;
objectKey: string;
}
Expand Down Expand Up @@ -49,7 +50,7 @@ export interface LiteralAst extends BaseAst {
}

export type Ast =
ArrayAst
| ArrayAst
| FunctionAst
| LiteralAst
| ObjectAst
Expand Down
30 changes: 27 additions & 3 deletions src/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
FunctionAst,
LiteralAst,
ObjectAst,
ObjectPropertyAst,
RegExpAst,
UnionAst,
ValueAst,
Expand Down Expand Up @@ -41,16 +42,39 @@ const compileFunction = (ast: FunctionAst): ValidationFn => ast.fn;
const compileValue = <T>(ast: ValueAst<T>): ValidationFn =>
valueIs(ast.key, ast.value, ast.name);

function compileObjectWithAnyProp(prop: ObjectPropertyAst): ValidationFn {
const anyFn = compile(prop.ast);

return (obj: any) => flatten(
Object
.keys(obj)
.map(p => anyFn(obj[p]).map(error => ({
...error,
// error.key is ['$any', 'a', 'b'] and we need to replace `$any` with
// the actual property name (p). For example { x: { a: { b: 1 } } }
// should error with ['x', 'a', 'b']
key: [p, ...error.key.slice(1)],
}))),
);
}

function compileObject(ast: ObjectAst): ValidationFn {
const anyProp = ast.properties.find(p => p.anyKey);

const fns: ValidationFn[] = ast.properties
.map(p => ({ ...p, fn: compile(p.ast) }))
.map(p => (obj: any) => p.fn(obj[p.objectKey]));
.filter(p => p !== anyProp)
.map(p => ({ ...p, fn: compile(p.ast) }))
.map(p => (obj: any) => p.fn(obj[p.objectKey]));

const allFns = ast.strict
? [...fns, noExtraKeys(ast.key, ast.properties.map(p => p.objectKey))]
: fns;

const fn = allOf(allFns);
const allFnsWithAny = anyProp
? [...allFns, compileObjectWithAnyProp(anyProp)]
: allFns;

const fn = allOf(allFnsWithAny);

return obj =>
isPlainObject(obj)
Expand Down
33 changes: 27 additions & 6 deletions src/from-ts/parse-to-ast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ const generateKeyOf = (node: ts.TypeReferenceNode): ObjectAst => ({
extendsFrom: [],
key: [],
properties: [{
anyKey: false,
ast: createTypeRef(getName(node.typeName)),
key: ['$keyof'],
objectKey: '$keyof',
Expand Down Expand Up @@ -203,7 +204,7 @@ const getPartialType = (node: ts.Node, options: Options) => {
};

const generateAttributeValue = (
node: ts.PropertySignature,
node: ts.PropertySignature | ts.IndexSignatureDeclaration,
options: Options,
): Ast => {
if (!node.type) throw `Invalid property value for ${node}`;
Expand Down Expand Up @@ -235,6 +236,20 @@ const generateAttribute = (
): ObjectPropertyAst => {
const name = getName(node.name);
return {
anyKey: false,
ast: generateAttributeValue(node, options),
key: [name],
objectKey: name,
};
};

const generateAnyKeyAttribute = (
node: ts.IndexSignatureDeclaration,
options: Options,
): ObjectPropertyAst => {
const name = '$any';
return {
anyKey: true,
ast: generateAttributeValue(node, options),
key: [name],
objectKey: name,
Expand All @@ -245,7 +260,7 @@ function generateObject(
node: ts.InterfaceDeclaration|ts.TypeLiteralNode,
options: Options,
): ObjectAst {
const strict = !node.members.find(ts.isIndexSignatureDeclaration);
const indexDeclaration = node.members.find(ts.isIndexSignatureDeclaration);

const extendsFrom = ts.isInterfaceDeclaration(node) && node.heritageClauses
? flatten(node.heritageClauses.map(c => [...c.types]))
Expand All @@ -254,13 +269,19 @@ function generateObject(
.map(t => getName(t as ts.Identifier))
: [];

const regularProperties = node.members
.filter(ts.isPropertySignature)
.map(m => generateAttribute(m, options));

const properties = indexDeclaration
? [...regularProperties, generateAnyKeyAttribute(indexDeclaration, options)]
: regularProperties;

return {
extendsFrom,
key: [],
properties: node.members
.filter(ts.isPropertySignature)
.map(m => generateAttribute(m, options)),
strict,
properties,
strict: true,
type: 'object',
};
}
Expand Down
12 changes: 8 additions & 4 deletions src/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ const notAnObject = (
}\` maps to a value of type \`${schema}\` (AST=${type})`,
);

function parseMakeOptional(
function parseObjectProperty(
parentKey: Key,
key: string,
schema: Type,
Expand All @@ -249,6 +249,7 @@ function parseMakeOptional(
}

return {
anyKey: key === '$any',
ast: isOptional
? makeOptional(fullKey, ast)
: ast,
Expand Down Expand Up @@ -282,12 +283,15 @@ function parseObject(
if (op) return op(key, schemaObject[schemaKeys[0]], options);
}

const properties = schemaKeys
.map(k => parseObjectProperty(key, k, schemaObject[k], options));

return {
extendsFrom: [],
key,
properties: schemaKeys
.map(k => parseMakeOptional(key, k, schemaObject[k], options)),
strict: (schemaObject as any).$strict !== false,
properties,
strict: (schemaObject as any).$strict !== false
&& properties.every(p => !p.anyKey),
type: 'object',
};
}
Expand Down

0 comments on commit 4bedb11

Please sign in to comment.