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

Remove additionalProperties from intersected objects. #65

Merged
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
22 changes: 11 additions & 11 deletions README.md
Expand Up @@ -66,17 +66,17 @@ You can pass a string as the second parameter of the main zodToJsonSchema functi

Instead of the schema name (or nothing), you can pass an options object as the second parameter. The following options are available:

| Option | Effect |
| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **name**?: _string_ | As described above. |
| **basePath**?: string[] | The base path of the root reference builder. Defaults to ["#"]. |
| **$refStrategy**?: "root" \| "relative" \| "none" | The reference builder strategy; <ul><li>**"root"** resolves $refs from the root up, ie: "#/definitions/mySchema".</li><li>**"relative"** uses [relative JSON pointers](https://tools.ietf.org/id/draft-handrews-relative-json-pointer-00.html). _See known issues!_</li><li>**"none"** ignores referencing all together, creating a new schema branch even on "seen" schemas. Recursive references defaults to "any", ie `{}`.</li></ul> Defaults to "root". |
| **effectStrategy**?: "input" \| "any" | The effects output strategy. Defaults to "input". _See known issues!_ |
| **definitionPath**?: "definitions" \| "$defs" | The name of the definitions property when name is passed. Defaults to "definitions". |
| **target**?: "jsonSchema7" \| "openApi3" | Which spec to target. Defaults to "jsonSchema7" |
| **strictUnions**?: boolean | Scrubs unions of any-like json schemas, like `{}` or `true`. Multiple zod types may result in these out of necessity, such as z.instanceof() |
| **definitions**?: Record<string, ZodSchema> | See separate section below |
| **errorMessages**?: boolean | Include custom error messages created via chained function checks for supported zod types. See section below |
| Option | Effect |
|-----------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **name**?: _string_ | As described above. |
| **basePath**?: string[] | The base path of the root reference builder. Defaults to ["#"]. |
| **$refStrategy**?: "root" \| "relative" \| "none" | The reference builder strategy; <ul><li>**"root"** resolves $refs from the root up, ie: "#/definitions/mySchema".</li><li>**"relative"** uses [relative JSON pointers](https://tools.ietf.org/id/draft-handrews-relative-json-pointer-00.html). _See known issues!_</li><li>**"none"** ignores referencing all together, creating a new schema branch even on "seen" schemas. Recursive references defaults to "any", ie `{}`.</li></ul> Defaults to "root". |
| **effectStrategy**?: "input" \| "any" | The effects output strategy. Defaults to "input". _See known issues!_ |
| **definitionPath**?: "definitions" \| "$defs" | The name of the definitions property when name is passed. Defaults to "definitions". |
| **target**?: "jsonSchema7" \| "jsonSchema2019-09" \| "openApi3" | Which spec to target. Defaults to "jsonSchema7" |
| **strictUnions**?: boolean | Scrubs unions of any-like json schemas, like `{}` or `true`. Multiple zod types may result in these out of necessity, such as z.instanceof() |
| **definitions**?: Record<string, ZodSchema> | See separate section below |
| **errorMessages**?: boolean | Include custom error messages created via chained function checks for supported zod types. See section below |

### Definitions

Expand Down
6 changes: 4 additions & 2 deletions src/Options.ts
@@ -1,6 +1,8 @@
import { ZodSchema } from "zod";

export type Options<Target extends "jsonSchema7" | "openApi3" = "jsonSchema7"> =
export type Targets = "jsonSchema7" | "jsonSchema2019-09" | "openApi3"

export type Options<Target extends Targets = "jsonSchema7"> =
{
name: string | undefined;
$refStrategy: "root" | "relative" | "none";
Expand All @@ -27,7 +29,7 @@ export const defaultOptions: Options = {
errorMessages: false,
};

export const getDefaultOptions = <Target extends "jsonSchema7" | "openApi3">(
export const getDefaultOptions = <Target extends Targets>(
options: Partial<Options<Target>> | string | undefined
) =>
(typeof options === "string"
Expand Down
6 changes: 3 additions & 3 deletions src/Refs.ts
@@ -1,12 +1,12 @@
import { ZodTypeDef } from "zod";
import { getDefaultOptions, Options } from "./Options";
import { getDefaultOptions, Options, Targets } from "./Options";
import { JsonSchema7Type } from "./parseDef";

export type Refs = {
seen: Map<ZodTypeDef, Seen>;
currentPath: string[];
propertyPath: string[] | undefined;
} & Options<"jsonSchema7" | "openApi3">;
} & Options<Targets>;

export type Seen = {
def: ZodTypeDef;
Expand All @@ -15,7 +15,7 @@ export type Seen = {
};

export const getRefs = (
options?: string | Partial<Options<"jsonSchema7" | "openApi3">>
options?: string | Partial<Options<Targets>>
): Refs => {
const _options = getDefaultOptions(options);
const currentPath =
Expand Down
42 changes: 41 additions & 1 deletion src/parsers/intersection.ts
@@ -1,11 +1,18 @@
import { ZodIntersectionDef } from "zod";
import { JsonSchema7Type, parseDef } from "../parseDef";
import { Refs } from "../Refs";
import { JsonSchema7StringType } from "./string";

export type JsonSchema7AllOfType = {
allOf: JsonSchema7Type[];
unevaluatedProperties?: boolean;
};

const isJsonSchema7AllOfType = (type: JsonSchema7Type | JsonSchema7StringType): type is JsonSchema7AllOfType => {
if ("type" in type && type.type === "string") return false;
return 'allOf' in type;
}

export function parseIntersectionDef(
def: ZodIntersectionDef,
refs: Refs
Expand All @@ -21,5 +28,38 @@ export function parseIntersectionDef(
}),
].filter((x): x is JsonSchema7Type => !!x);

return allOf.length ? { allOf } : undefined;

let unevaluatedProperties: Pick<JsonSchema7AllOfType, 'unevaluatedProperties'> | undefined =
refs.target === 'jsonSchema2019-09' ? {unevaluatedProperties: false} : undefined;

const mergedAllOf: JsonSchema7Type[] = []
// If either of the schemas is an allOf, merge them into a single allOf
allOf.forEach((schema) => {
if (isJsonSchema7AllOfType(schema)) {

mergedAllOf.push(...schema.allOf);
if (schema.unevaluatedProperties === undefined) {
// If one of the schemas has no unevaluatedProperties set,
// the merged schema should also have no unevaluatedProperties set
unevaluatedProperties = undefined;
}

} else {

let nestedSchema: JsonSchema7Type = schema
if ('additionalProperties' in schema && schema.additionalProperties === false) {
const {additionalProperties, ...rest} = schema;
nestedSchema = rest;
} else {
// As soon as one of the schemas has additionalProperties set not to false, we allow unevaluatedProperties
unevaluatedProperties = undefined;
}
mergedAllOf.push(nestedSchema);

}
});
return mergedAllOf.length ? {
allOf: mergedAllOf,
...unevaluatedProperties
Andy2003 marked this conversation as resolved.
Show resolved Hide resolved
} : undefined;
}
8 changes: 5 additions & 3 deletions src/zodToJsonSchema.ts
@@ -1,17 +1,17 @@
import { ZodSchema } from "zod";
import { Options } from "./Options";
import { Options, Targets } from "./Options";
import { JsonSchema7Type, parseDef } from "./parseDef";
import { getRefs } from "./Refs";

const zodToJsonSchema = <
Target extends "jsonSchema7" | "openApi3" = "jsonSchema7"
Target extends Targets = "jsonSchema7"
>(
schema: ZodSchema<any>,
options?: Partial<Options<Target>> | string
): (Target extends "jsonSchema7" ? JsonSchema7Type : object) & {
$schema?: string;
definitions?: {
[key: string]: Target extends "jsonSchema7" ? JsonSchema7Type : object;
[key: string]: Target extends "jsonSchema7" ? JsonSchema7Type : Target extends "jsonSchema2019-09" ? JsonSchema7Type: object;
};
} => {
const refs = getRefs(options);
Expand Down Expand Up @@ -66,6 +66,8 @@ const zodToJsonSchema = <

if (refs.target === "jsonSchema7") {
combined.$schema = "http://json-schema.org/draft-07/schema#";
} else if (refs.target === "jsonSchema2019-09") {
combined.$schema = "https://json-schema.org/draft/2019-09/schema#";
}

return combined;
Expand Down
164 changes: 164 additions & 0 deletions test/parsers/intersection.test.ts
Expand Up @@ -38,4 +38,168 @@ describe("intersections", () => {
],
});
});

it("should intersect complex objects correctly", () => {
const schema1 = z.object({
foo: z.string()
});
const schema2 = z.object({
bar: z.string()
});
const intersection = z.intersection(schema1, schema2);
const jsonSchema = parseIntersectionDef(intersection._def, getRefs({target: "jsonSchema2019-09"}));

expect(jsonSchema).toStrictEqual({
allOf: [
{
properties: {
foo: {
type: "string",
}
},
required: ["foo"],
type: "object",
},
{
properties: {
bar: {
type: "string",
}
},
required: ["bar"],
type: "object",
}
],
unevaluatedProperties: false,
});
});

it("should return `unevaluatedProperties` only if all sub-schemas has additionalProperties set to false", () => {
const schema1 = z.object({
foo: z.string()
});
const schema2 = z.object({
bar: z.string()
}).passthrough();
const intersection = z.intersection(schema1, schema2);
const jsonSchema = parseIntersectionDef(intersection._def, getRefs({target: "jsonSchema2019-09"}));

expect(jsonSchema).toStrictEqual({
allOf: [
{
properties: {
foo: {
type: "string",
}
},
required: ["foo"],
type: "object",
},
{
properties: {
bar: {
type: "string",
}
},
required: ["bar"],
type: "object",
additionalProperties: true,
}
],
});
});

it("should intersect multiple complex objects correctly", () => {
const schema1 = z.object({
foo: z.string()
});
const schema2 = z.object({
bar: z.string()
});
const schema3 = z.object({
baz: z.string()
});
const intersection = schema1.and(schema2).and(schema3);
const jsonSchema = parseIntersectionDef(intersection._def, getRefs({target: "jsonSchema2019-09"}));

expect(jsonSchema).toStrictEqual({
allOf: [
{
properties: {
foo: {
type: "string",
}
},
required: ["foo"],
type: "object",
},
{
properties: {
bar: {
type: "string",
}
},
required: ["bar"],
type: "object",
},
{
properties: {
baz: {
type: "string",
}
},
required: ["baz"],
type: "object",
},
],
unevaluatedProperties: false,
});
});

it("should return `unevaluatedProperties` only if all of the multiple sub-schemas has additionalProperties set to false", () => {
const schema1 = z.object({
foo: z.string()
});
const schema2 = z.object({
bar: z.string()
});
const schema3 = z.object({
baz: z.string()
}).passthrough();
const intersection = schema1.and(schema2).and(schema3);
const jsonSchema = parseIntersectionDef(intersection._def, getRefs({target: "jsonSchema2019-09"}));

expect(jsonSchema).toStrictEqual({
allOf: [
{
properties: {
foo: {
type: "string",
}
},
required: ["foo"],
type: "object",
},
{
properties: {
bar: {
type: "string",
}
},
required: ["bar"],
type: "object",
},
{
additionalProperties: true,
properties: {
baz: {
type: "string",
}
},
required: ["baz"],
type: "object",
},
]
});
});
});