Skip to content

Commit

Permalink
🔀 Merge pull request #65 from Andy2003/bugfix/gh-64-fix-intersected-t…
Browse files Browse the repository at this point in the history
…ypes

Remove additionalProperties from intersected objects.
  • Loading branch information
StefanTerdell committed May 11, 2023
2 parents f2d2704 + 631e1af commit c6835a4
Show file tree
Hide file tree
Showing 6 changed files with 228 additions and 20 deletions.
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
} : 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",
},
]
});
});
});

0 comments on commit c6835a4

Please sign in to comment.