Skip to content

Commit

Permalink
Improve object structure validation (#157)
Browse files Browse the repository at this point in the history
Adds back the strict 1:1 structure validation. By default it also validates the prototype chain. Fixes the issue with the type of the `missing` option.

## Commits

* Add strict keys validation, missing type fix, own option

* Remove .vimrc

* Bump @toi/[email protected]

* Oops, fix previous commit

* Address remarks
  • Loading branch information
hf authored Mar 19, 2020
1 parent dfdbed8 commit ad0fb9b
Show file tree
Hide file tree
Showing 4 changed files with 156 additions and 38 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ coverage
*.lock
.nyc_output
.npmrc
.vimrc
*.swp
96 changes: 83 additions & 13 deletions packages/toi/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,8 +366,15 @@ describe("toi", () => {
b: toi.required().and(toi.num.is())
}),
{
positive: [{ a: "", b: 0 }, { a: "hello", b: 2 }, { a: "a", b: 0, c: "c" }],
negative: [{ a: 0, b: 0 }, { a: "hello", b: "world" }, {}, { a: "hello" }, { b: 0 }]
positive: [{ a: "", b: 0 }, { a: "hello", b: 2 }],
negative: [
{ a: "a", b: 0, c: "c" },
{ a: 0, b: 0 },
{ a: "hello", b: "world" },
{},
{ a: "hello" },
{ b: 0 }
]
}
);
});
Expand Down Expand Up @@ -415,34 +422,97 @@ describe("toi", () => {
});
});

describe("keys({ a: toi.num.is(), b: toi.num.is() }, { missing: { a: true } })", () => {
describe("keys({ a: toi.num.is(), b: toi.num.is() }, { missing: ['a'] })", () => {
const validator = toi.obj.keys(
{
a: toi.num.is(),
b: toi.num.is()
},
{ missing: ["a"] }
);

assert(validator, {
positive: [{ a: 0, b: 0 }, { b: 0 }],
negative: [{ a: 0 }, {}]
});
});

describe("keys({ a: toi.required().and(toi.num.is()), b: toi.num.is() }, { missing: ['a']} })", () => {
const validator = toi.obj.keys(
{
a: toi.required().and(toi.num.is()),
b: toi.num.is()
},
{ missing: ["a"] }
);

assert(validator, {
positive: [{ a: 0, b: 0 }],
negative: [{ a: 0 }, { b: 0 }, {}]
});
});

describe("keys({ a: toi.required().and(toi.num.is()), b: toi.required().and(toi.func.is()) })", () => {
class B {
b() {
return 1;
}
}

class A extends B {
a = 1;
}

assert(
toi.obj.keys({
a: toi.required().and(toi.num.is()),
b: toi.required().and(toi.func.is())
}),
{
positive: [new A(), { a: 1, b: () => 1 }],
negative: [new B(), {}, { a: 1 }, { b: 1 }, { a: 1, b: 1 }]
}
);
});

describe("keys({ a: toi.required().and(toi.num.is()), b: toi.required().and(toi.func.is()) }, { own: true })", () => {
class B {
b() {
return 1;
}
}

class A extends B {
a = 1;
}

assert(
toi.obj.keys(
{
a: toi.num.is(),
b: toi.num.is()
a: toi.required().and(toi.num.is()),
b: toi.required().and(toi.func.is())
},
{ missing: ["a"] }
{ own: true }
),
{
positive: [{ a: 0, b: 0 }, { b: 0 }],
negative: [{ a: 0 }, {}]
positive: [{ a: 1, b: () => 1 }],
negative: [new A(), new B(), {}, { a: 1 }, { b: 1 }, { a: 1, b: 1 }]
}
);
});

describe("keys({ a: toi.required().and(toi.num.is()), b: toi.num.is() }, { missing: { a: true } })", () => {
describe("keys({ a: toi.required().and(toi.num.is()), b: toi.required().and(toi.str.is()) }, { lenient: true })", () => {
assert(
toi.obj.keys(
{
a: toi.required().and(toi.num.is()),
b: toi.num.is()
b: toi.required().and(toi.str.is())
},
{ missing: ["a"] }
{ lenient: true }
),
{
positive: [{ a: 0, b: 0 }],
negative: [{ a: 0 }, { b: 0 }, {}]
positive: [{ a: 1, b: "1" }, { a: 1, b: "1", c: "2" }],
negative: [{}, { a: 1 }, { b: "1" }, { a: "1", b: "1" }, { c: 1 }]
}
);
});
Expand Down
94 changes: 70 additions & 24 deletions packages/toi/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -647,69 +647,115 @@ export namespace obj {
);

/**
* Checks that the object obeys a strict structure of properties and property-level validators.
* Checks that the object obeys a strict structure of properties and property-level validators. The structure needs to define all properties in a plain JavaScript object and its prototype chain is not inspected.
*
* It is a strict 1:1 validation. Keys not found on the value will trigger a {@link ValidationError},
* including keys found in the `value` but not in the structure.
* including keys found in the `value` but not in the structure. This excludes non-enumerable properties (like `toString` or other built-in functions) or properties defined on symbols.
* You can disable this strict 1:1 mapping by setting the `lenient` option to `true.
*
* The `value` is never returned as-is, but is made a copy of with the own properties according to
* The prototype chain of the passed value will be walked and inherited properties will be validated. You can disable this by setting the `own` option to `true`.
*
* The `value` is never returned as-is, but is made a copy of with the properties according to
* the defined `structure`. Therefore, the property-level validators may transform the property
* values. This safeguards from `instanceof` checks, including prototype-hijacking and similar
* issues.
* issues. The returned value will always have the Object prototype, as the prototype chain is not copied.
*
* @param structure the definition structure of the object
* @param options the validation options, like optional (missing) fields
* @param options the validation options
*/
export const keys = <X extends object, Y>(
export const keys = <X extends object, Y, M extends Y>(
structure: { [K in keyof Y]: Validator<any, Y[K]> },
options?: { missing?: (keyof Y)[] }
options: {
/** Set the keys that may be missing in the value. */
missing?: (keyof M)[];
/** Only validate the value's own properties and ignore inherited properties. */
own?: boolean;
/** Don't validate that value matches 1:1 with the structure. */
lenient?: boolean;
} = {}
) => {
const missing: { [key in keyof Y]?: true } = {};
const missing: { [key in keyof M]?: true } = {};

if (options && options.missing && options.missing.length > 0) {
for (let i = 0; i < options.missing.length; i += 1) {
missing[options.missing[i]] = true;
}
}

const walkPrototype = !options.own;

return wrap<X, Y>("obj.keys", value => {
if (null === value || undefined === value) {
return value;
}

let reasons: {
let hasReasons = false;
const reasons: {
[key: string]: ValidationError;
[key: number]: ValidationError;
} | null = null;
} = {};

const output: any = {};

for (let key in structure) {
try {
if (!Object.getOwnPropertyDescriptor(value, key)) {
if (!missing[key]) {
throw new ValidationError(`key ${key} in value is missing`, key);
let inspect: any = value;

do {
if (!Object.prototype.hasOwnProperty.call(inspect, key)) {
if ((missing as any)[key]) {
// still run the validator even if the value is missing, so that if
// someone has said that the key can be missing but they've put a required validator
// the validation will fail
output[key] = (structure as any)[key]((inspect as any)[key]);
break;
} else if (!walkPrototype) {
// value is not allowed to be missing, and we're not allowed
// to walk the prototype chain to find the value
// therefore this validation must fail
throw new ValidationError(`own key ${key} in value is missing`, key);
} else {
// we're allowed to walk the prototype and we're not allowed to
// have this property missing, so we're going to try and find it
// in the prototype chain... when we don't find it there we'll
// exit this loop and throw a validation error that we couldn't
// find it
}
} else {
// still run the validator even if the value is missing, so that if
// someone has said that the key can be missing but they've put a required validator
// the validation will fail
output[key] = (structure as any)[key]((value as any)[key]);
output[key] = (structure as any)[key]((inspect as any)[key]);
break;
}
} else {
output[key] = (structure as any)[key]((value as any)[key]);

inspect = Object.getPrototypeOf(inspect);
} while (walkPrototype && null !== inspect);

if (walkPrototype && null === inspect) {
throw new ValidationError(`key ${key} in value (and prototype chain) is missing`, key);
}
} catch (error) {
isError(error, ValidationError, () => {
if (!reasons) {
reasons = {};
}

reasons[key] = error;
hasReasons = true;
});
}
}

if (reasons) {
if (!options.lenient) {
for (let key in value) {
try {
if (!Object.prototype.hasOwnProperty.call(structure, key)) {
throw new ValidationError(`key ${key} found in value but not in structure`, key);
}
} catch (error) {
isError(error, ValidationError, () => {
reasons[key] = error;
hasReasons = true;
});
}
}
}

if (hasReasons) {
throw new ValidationError("value does not match structure", value, reasons);
}

Expand Down
2 changes: 1 addition & 1 deletion packages/toi/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@toi/toi",
"version": "1.2.0",
"version": "1.3.0",
"description": "Toi is a validator for TypeScript.",
"main": "build/index.js",
"files": [
Expand Down

0 comments on commit ad0fb9b

Please sign in to comment.