Skip to content

Instantly share code, notes, and snippets.

@safareli
Last active October 10, 2023 19:47
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save safareli/6201c8fc1ad6974891cdca3b9f13ef6a to your computer and use it in GitHub Desktop.
Save safareli/6201c8fc1ad6974891cdca3b9f13ef6a to your computer and use it in GitHub Desktop.
export {};
// https://gist.github.com/rattrayalex/b85d99d4428ee9c1f51661af324c767b
type SchemaPrimitive =
| {
type: "integer";
}
| {
type: "string";
};
type Schema = SchemaPrimitive | SchemaObject | SchemaOneOf;
type SchemaOneOf = {
type: "oneOf";
options: Schema[];
};
type SchemaObject = {
type: "object";
properties: Record<string, Schema>;
};
type Segment = string;
type NonEmptyArray<T> = Array<T> & { [0]: T };
function isNonEmptyArray<T>(arr: Array<T>): arr is NonEmptyArray<T> {
return arr.length > 0;
}
type Error = {
path: Segment[];
failedSchema: Schema;
} & (
| {
type: "missing_key";
key: string;
}
| {
type: "extra_keys";
keys: NonEmptyArray<string>;
}
| {
type: "failed_schema";
}
);
function validate(schema: Schema, object: any): boolean {
return validateOrError(schema, object).length === 0;
}
function validateOrError(schema: Schema, object: any): Error[] {
return validateOrError_(schema, object, []);
}
function validateOrError_(
schema: Schema,
object: any,
path: Segment[]
): Error[] {
switch (schema.type) {
case "integer":
if (typeof object === "number" && Number.isInteger(object)) {
return [];
}
return [{ type: "failed_schema", path, failedSchema: schema }];
case "string":
if (typeof object === "string") {
return [];
}
return [{ type: "failed_schema", path, failedSchema: schema }];
case "oneOf":
for (const s of schema.options) {
if (validate(s, object)) {
return [];
}
}
return [{ type: "failed_schema", path, failedSchema: schema }];
case "object":
if (typeof object !== "object" || object === null) {
return [{ type: "failed_schema", path, failedSchema: schema }];
}
const keys = new Set(Object.keys(object));
let errors: Error[] = [];
for (const [key, s] of Object.entries(schema.properties)) {
keys.delete(key);
if (Object.hasOwn(object, key)) {
for (const error of validateOrError_(s, object[key], [
...path,
key,
])) {
errors.push(error);
}
} else {
errors.push({
path: path,
failedSchema: schema,
type: "missing_key",
key,
});
}
}
// errors should be empty
const keysArr = Array.from(keys.values());
if (isNonEmptyArray(keysArr)) {
errors.push({
path: path,
failedSchema: schema,
type: "extra_keys",
keys: keysArr,
});
}
return errors;
}
return [];
}
describe("validateOrError", () => {
it("returns errors", () => {
const schema: Schema = {
type: "object",
properties: {
a: { type: "integer" },
b: { type: "string" },
},
};
const errors = validateOrError(schema, { a: "foo", z: 12 });
expect(errors).toMatchInlineSnapshot(`
Array [
Object {
"failedSchema": Object {
"type": "integer",
},
"path": Array [
"a",
],
"type": "failed_schema",
},
Object {
"failedSchema": Object {
"properties": Object {
"a": Object {
"type": "integer",
},
"b": Object {
"type": "string",
},
},
"type": "object",
},
"key": "b",
"path": Array [],
"type": "missing_key",
},
Object {
"failedSchema": Object {
"properties": Object {
"a": Object {
"type": "integer",
},
"b": Object {
"type": "string",
},
},
"type": "object",
},
"keys": Array [
"z",
],
"path": Array [],
"type": "extra_keys",
},
]
`);
});
});
it("should validate oneOf schemas correctly", () => {
const schema: Schema = {
type: "oneOf",
options: [{ type: "integer" }, { type: "string" }],
};
expect(validate(schema, "foo")).toEqual(true);
expect(validate(schema, 12)).toEqual(true);
expect(validate(schema, 12.12)).toEqual(false);
expect(validate(schema, { a: 12.12 })).toEqual(false);
expect(validate({ type: "oneOf", options: [] }, 12)).toEqual(false);
expect(validate({ type: "oneOf", options: [] }, "12")).toEqual(false);
});
it("should validate objects correctly", () => {
const spec: Schema = {
type: "object",
properties: {
a: { type: "string" },
b: { type: "integer" },
},
};
expect(validate(spec, { a: "foo", b: 10 })).toEqual(true);
expect(validate(spec, { a: "foo", z: 12, b: 10 })).toEqual(false);
expect(validate(spec, { a: "foo", b: "foo" })).toEqual(false);
});
it("recursive test", () => {
const spec: Schema = {
type: "object",
properties: {
a: { type: "string" },
b: { type: "integer" },
self: {
type: "object",
properties: {
a: { type: "string" },
b: { type: "integer" },
},
},
},
};
expect(
validate(spec, { a: "foo", b: 10, self: { a: "foo", b: 10 } })
).toEqual(true);
});
it("should validate integers correctly", () => {
const spec: Schema = { type: "integer" };
expect(validate(spec, 100)).toEqual(true);
expect(validate(spec, 100.12)).toEqual(false);
expect(validate(spec, {})).toEqual(false);
expect(validate(spec, "string")).toEqual(false);
});
it("should validate strings correctly", () => {
const spec: Schema = { type: "string" };
expect(validate(spec, 100)).toEqual(false);
expect(validate(spec, 100.12)).toEqual(false);
expect(validate(spec, "string")).toEqual(true);
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment