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: Support unevaluated properties keyword #197

Merged
merged 3 commits into from
May 4, 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
58 changes: 55 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -373,9 +373,9 @@ type Object = FromSchema<
// => { foo?: string; }
```

`FromSchema` partially supports the `additionalProperties` and `patternProperties` keywords:
`FromSchema` partially supports the `additionalProperties`, `patternProperties` and `unevaluatedProperties` keywords:

- `additionalProperties` can be used to deny additional properties.
- `additionalProperties` and `unevaluatedProperties` can be used to deny additional properties.

```typescript
const closedObjectSchema = {
Expand All @@ -387,6 +387,29 @@ type Object = FromSchema<typeof closedObjectSchema>;
// => { foo: string; bar?: number; }
```

```typescript
const closedObjectSchema = {
type: "object",
allOf: [
{
properties: {
foo: { type: "string" },
},
required: ["foo"],
},
{
properties: {
bar: { type: "number" },
},
},
],
unevaluatedProperties: false,
} as const;

type Object = FromSchema<typeof closedObjectSchema>;
// => { foo: string; bar?: number; }
```

- Used on their own, `additionalProperties` and/or `patternProperties` can be used to type unnamed properties.

```typescript
Expand All @@ -405,7 +428,36 @@ type Object = FromSchema<typeof openObjectSchema>;
// => { [x: string]: string | number | boolean }
```

- However, when used in combination with the `properties` keyword, extra properties will always be typed as `unknown` to avoid conflicts.
However:

- When used in combination with the `properties` keyword, extra properties will always be typed as `unknown` to avoid conflicts.

```typescript
const mixedObjectSchema = {
type: "object",
properties: {
foo: { enum: ["bar", "baz"] },
},
additionalProperties: { type: "string" },
} as const;

type Object = FromSchema<typeof mixedObjectSchema>;
// => { [x: string]: unknown; foo?: "bar" | "baz"; }
```

- Due to its context-dependent nature, `unevaluatedProperties` does not type extra-properties when used on its own. Use `additionalProperties` instead.

```typescript
const openObjectSchema = {
type: "object",
unevaluatedProperties: {
type: "boolean",
},
} as const;

type Object = FromSchema<typeof openObjectSchema>;
// => { [x: string]: unknown }
```

## Combining schemas

Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
},
"dependencies": {
"@babel/runtime": "^7.18.3",
"ts-algebra": "^1.2.2"
"ts-algebra": "^2.0.0"
},
"devDependencies": {
"@babel/cli": "^7.17.6",
Expand All @@ -41,7 +41,7 @@
"@typescript-eslint/eslint-plugin": "^6.13.2",
"@typescript-eslint/parser": "^6.13.2",
"@zerollup/ts-transform-paths": "^1.7.18",
"ajv": "^8.10.0",
"ajv": "^8.13.0",
"babel-plugin-module-resolver": "^4.1.0",
"dependency-cruiser": "^11.18.0",
"eslint": "^8.27.0",
Expand Down Expand Up @@ -86,4 +86,4 @@
"url": "https://github.com/ThomasAribart/json-schema-to-ts/issues"
},
"homepage": "https://github.com/ThomasAribart/json-schema-to-ts#readme"
}
}
1 change: 1 addition & 0 deletions src/definitions/jsonSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export type JSONSchema =
properties?: Readonly<Record<string, JSONSchema>>;
patternProperties?: Readonly<Record<string, JSONSchema>>;
additionalProperties?: JSONSchema;
unevaluatedProperties?: JSONSchema;
dependencies?: Readonly<Record<string, JSONSchema | readonly string[]>>;
propertyNames?: JSONSchema;

Expand Down
4 changes: 2 additions & 2 deletions src/parse-schema/ajv.util.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import Ajv from "ajv";
import Ajv2019 from "ajv/dist/2019";

export const ajv = new Ajv({ strict: false });
export const ajv = new Ajv2019({ strict: false });
61 changes: 61 additions & 0 deletions src/parse-schema/allOf.unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { A } from "ts-toolbelt";

import type { FromSchema } from "~/index";

import { ajv } from "./ajv.util.test";
Expand Down Expand Up @@ -477,6 +479,65 @@ describe("AllOf schemas", () => {
expect(ajv.validate(objectSchema, objectInstance)).toBe(false);
});
});

describe("Open to open object (w. unevaluated properties)", () => {
// Example from https://json-schema.org/understanding-json-schema/reference/object#unevaluatedproperties
const addressSchema = {
type: "object",
allOf: [
{
type: "object",
properties: {
street_address: { type: "string" },
city: { type: "string" },
state: { type: "string" },
},
required: ["street_address", "city", "state"],
},
],
properties: {
type: { enum: ["residential", "business"] },
},
required: ["type"],
unevaluatedProperties: false,
} as const;

type Address = FromSchema<typeof addressSchema>;
let address: Address;

type ExpectedAddress = {
street_address: string;
city: string;
state: string;
type: "residential" | "business";
};

type AssertAddress = A.Equals<Address, ExpectedAddress>;
const assertAddress: AssertAddress = 1;
assertAddress;

it("accepts valid objects", () => {
address = {
street_address: "1600 Pennsylvania Avenue NW",
city: "Washington",
state: "DC",
type: "business",
};
expect(ajv.validate(addressSchema, address)).toBe(true);
});

it("rejects unevaluated properties", () => {
address = {
street_address: "1600 Pennsylvania Avenue NW",
city: "Washington",
state: "DC",
type: "business",
// @ts-expect-error
"something that doesn't belong": "hi!",
};
expect(ajv.validate(addressSchema, address)).toBe(false);
});
});
});

describe("Factored tuple properties", () => {
Expand Down
76 changes: 76 additions & 0 deletions src/parse-schema/ifThenElse.unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { A } from "ts-toolbelt";

import type { FromSchema } from "~/index";

import { ajv } from "./ajv.util.test";
Expand Down Expand Up @@ -155,6 +157,80 @@ describe("If/Then/Else schemas", () => {
});
});

describe("Closed to closed object (unevaluated properties)", () => {
// Example from https://json-schema.org/understanding-json-schema/reference/object#unevaluatedproperties
const addressSchema = {
type: "object",
properties: {
street_address: { type: "string" },
city: { type: "string" },
state: { type: "string" },
type: { enum: ["residential", "business"] },
},
required: ["street_address", "city", "state", "type"],
if: {
type: "object",
properties: {
type: { const: "business" },
},
required: ["type"],
},
then: {
properties: {
department: { type: "string" },
},
},
unevaluatedProperties: false,
} as const;

type Address = FromSchema<
typeof addressSchema,
{ parseIfThenElseKeywords: true }
>;
let address: Address;

type ExpectedAddress =
| {
street_address: string;
city: string;
state: string;
type: "business";
department?: string | undefined;
}
| {
street_address: string;
city: string;
state: string;
type: "residential";
};
type AssertAddress = A.Equals<Address, ExpectedAddress>;
const assertAddress: AssertAddress = 1;
assertAddress;

it("accepts valid objects", () => {
address = {
street_address: "1600 Pennsylvania Avenue NW",
city: "Washington",
state: "DC",
type: "business",
department: "HR",
};
expect(ajv.validate(addressSchema, address)).toBe(true);
});

it("rejects unevaluated properties", () => {
address = {
street_address: "1600 Pennsylvania Avenue NW",
city: "Washington",
state: "DC",
type: "residential",
// @ts-expect-error
department: "HR",
};
expect(ajv.validate(addressSchema, address)).toBe(false);
});
});

describe("additional items (incorrect)", () => {
const petSchema = {
type: "array",
Expand Down
3 changes: 1 addition & 2 deletions src/parse-schema/nullable.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,10 +151,9 @@ describe("Nullable schemas", () => {
});

it("rejects null on non-nullable property", () => {
// TOIMPROVE: Fix this: Use of allOf breaks the ifThenElse if exclusion somehow (works fine without allOf)
// @ts-expect-error
objectInst = {
preventNullable: "true",
// @ts-NOT-expect-error
potentiallyNullable: null,
};
expect(ajv.validate(objectWithNullablePropSchema, objectInst)).toBe(
Expand Down
17 changes: 15 additions & 2 deletions src/parse-schema/object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,14 @@ export type ParseObjectSchema<
>;
},
GetRequired<OBJECT_SCHEMA, OPTIONS>,
GetOpenProps<OBJECT_SCHEMA, OPTIONS>
GetOpenProps<OBJECT_SCHEMA, OPTIONS>,
GetClosedOnResolve<OBJECT_SCHEMA>
>
: M.$Object<
{},
GetRequired<OBJECT_SCHEMA, OPTIONS>,
GetOpenProps<OBJECT_SCHEMA, OPTIONS>
GetOpenProps<OBJECT_SCHEMA, OPTIONS>,
GetClosedOnResolve<OBJECT_SCHEMA>
>;

/**
Expand Down Expand Up @@ -100,6 +102,17 @@ type GetOpenProps<
? PatternProps<OBJECT_SCHEMA["patternProperties"], OPTIONS>
: M.Any;

/**
* Extracts and parses the unevaluated properties (if any exists) of an object JSON schema
* @param OBJECT_SCHEMA JSONSchema (object type)
* @param OPTIONS Parsing options
* @returns String
*/
type GetClosedOnResolve<OBJECT_SCHEMA extends ObjectSchema> =
OBJECT_SCHEMA extends Readonly<{ unevaluatedProperties: false }>
? true
: false;

/**
* Extracts and parses the pattern properties of an object JSON schema
* @param PATTERN_PROPERTY_SCHEMAS Record<string, JSONSchema>
Expand Down
48 changes: 48 additions & 0 deletions src/parse-schema/object.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,54 @@ describe("Object schemas", () => {
});
});

describe("Unevaluated properties", () => {
const setSchema = {
type: "object",
unevaluatedProperties: { type: "boolean" },
} as const;

type Set = FromSchema<typeof setSchema>;
let setInstance: Set;

it("accepts object with boolean values", () => {
setInstance = { a: true, b: false };
expect(ajv.validate(setSchema, setInstance)).toBe(true);
});

it("rejects object with other values", () => {
// We do not handle this case for the moment
// @ts-NOT-expect-error
setInstance = { a: 42 };
expect(ajv.validate(setSchema, setInstance)).toBe(false);
});

it("prioritizes additionalProperties and/or patternProterties if one is specified", () => {
const setSchema2 = {
type: "object",
additionalProperties: { type: "boolean" },
unevaluatedProperties: { const: false },
} as const;

type Set2 = FromSchema<typeof setSchema2>;
const setInstance2: Set2 = { foo: true };

expect(ajv.validate(setSchema2, setInstance2)).toBe(true);

const setSchema3 = {
type: "object",
patternProperties: {
"^f": { type: "boolean" },
},
unevaluatedProperties: { const: false },
} as const;

type Set3 = FromSchema<typeof setSchema3>;
const setInstance3: Set3 = { foo: true };

expect(ajv.validate(setSchema3, setInstance3)).toBe(true);
});
});

describe("Properties", () => {
const catSchema = {
type: "object",
Expand Down
Loading
Loading