Skip to content

Commit

Permalink
feat: add support for any JSON object as default (#275)
Browse files Browse the repository at this point in the history
* chore: quick refacto to avoid extra computation

* feat: add support for any JSON object as default

* ci: temporarily disabling coverage report

* fix: lint with more specific type

* clean: a bit of refacto for readibility
  • Loading branch information
tvillaren authored Nov 24, 2024
1 parent b67878c commit 476180f
Show file tree
Hide file tree
Showing 4 changed files with 154 additions and 36 deletions.
1 change: 0 additions & 1 deletion .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,4 @@ jobs:
- run: chmod +x ./bin/run
- run: yarn build
- run: yarn test:ci
- run: yarn codecov
- run: yarn lint
61 changes: 61 additions & 0 deletions src/core/generateZodSchema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1582,6 +1582,67 @@ describe("generateZodSchema", () => {
`);
});

it("should deal with @default with array value", () => {
const source = `export interface WithArrayDefault {
/**
* @default ["superman", "batman", "wonder woman"]
*/
justiceLeague: string[];
}`;
expect(generate(source)).toMatchInlineSnapshot(`
"export const withArrayDefaultSchema = z.object({
/**
* @default ["superman", "batman", "wonder woman"]
*/
justiceLeague: z.array(z.string()).default(["superman", "batman", "wonder woman"])
});"
`);
});

it("should deal with @default with object value", () => {
const source = `export interface WithObjectDefault {
/**
* @default { "name": "Clark Kent", "age": 30, "isHero": true }
*/
superman: { name: string; age: number; isHero: boolean };
}`;
expect(generate(source)).toMatchInlineSnapshot(`
"export const withObjectDefaultSchema = z.object({
/**
* @default { "name": "Clark Kent", "age": 30, "isHero": true }
*/
superman: z.object({
name: z.string(),
age: z.number(),
isHero: z.boolean()
}).default({ "name": "Clark Kent", "age": 30, "isHero": true })
});"
`);
});

it("should deal with @default with nested array and object values", () => {
const source = `export interface WithNestedDefault {
/**
* @default { "heroes": ["superman", "batman"], "villains": [{ "name": "Lex Luthor", "age": 40 }] }
*/
dcUniverse: { heroes: string[]; villains: { name: string; age: number }[] };
}`;
expect(generate(source)).toMatchInlineSnapshot(`
"export const withNestedDefaultSchema = z.object({
/**
* @default { "heroes": ["superman", "batman"], "villains": [{ "name": "Lex Luthor", "age": 40 }] }
*/
dcUniverse: z.object({
heroes: z.array(z.string()),
villains: z.array(z.object({
name: z.string(),
age: z.number()
}))
}).default({ "heroes": ["superman", "batman"], "villains": [{ "name": "Lex Luthor", "age": 40 }] })
});"
`);
});

it("should ignore unknown/broken jsdoc format", () => {
const source = `export interface Hero {
/**
Expand Down
26 changes: 15 additions & 11 deletions src/core/generateZodSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,23 +311,27 @@ function buildZodPrimitive({
| ts.Identifier
| ts.PropertyAccessExpression {
const schema = jsDocTags.schema;

// Schema override when it doesn't start with a dot, return the schema directly
if (schema && !schema.startsWith(".")) {
return f.createPropertyAccessExpression(
f.createIdentifier(z),
f.createIdentifier(schema)
);
}

delete jsDocTags.schema;
const generatedSchema = buildZodPrimitiveInternal({ jsDocTags, z, ...rest });
// schema not specified? return generated one

// No schema override? Return generated one
if (!schema) {
return generatedSchema;
}
// schema starts with dot? append it
if (schema.startsWith(".")) {
return f.createPropertyAccessExpression(
generatedSchema,
f.createIdentifier(schema.slice(1))
);
}
// otherwise use provided schema verbatim

// Schema override starts with dot? Append it
return f.createPropertyAccessExpression(
f.createIdentifier(z),
f.createIdentifier(schema)
generatedSchema,
f.createIdentifier(schema.slice(1))
);
}

Expand Down
102 changes: 78 additions & 24 deletions src/core/jsDocTags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ const builtInJSDocFormatsTypes = [

type BuiltInJSDocFormatsType = (typeof builtInJSDocFormatsTypes)[number];

type JsonPrimitive = string | number | boolean | null;
type JsonArray = JsonValue[];
type JsonObject = { [key: string]: JsonValue };
type JsonValue = JsonPrimitive | JsonArray | JsonObject;

/**
* Type guard to filter supported JSDoc format tag values (built-in).
*
Expand Down Expand Up @@ -58,7 +63,7 @@ export interface JSDocTagsBase {
description?: string;
minimum?: TagWithError<number>;
maximum?: TagWithError<number>;
default?: number | string | boolean | null;
default?: JsonValue;
minLength?: TagWithError<number>;
maxLength?: TagWithError<number>;
format?: TagWithError<BuiltInJSDocFormatsType | CustomJSDocFormatType>;
Expand Down Expand Up @@ -191,28 +196,15 @@ export function getJSDocTags(nodeType: ts.Node, sourceFile: ts.SourceFile) {
jsDocTags[tagName] = { value, errorMessage };
break;
case "default":
if (tag.comment === "null") {
jsDocTags[tagName] = null;
} else if (
tag.comment &&
!tag.comment.includes('"') &&
!Number.isNaN(parseInt(tag.comment))
) {
// number
jsDocTags[tagName] = parseInt(tag.comment);
} else if (tag.comment && ["false", "true"].includes(tag.comment)) {
// boolean
jsDocTags[tagName] = tag.comment === "true";
} else if (
tag.comment &&
tag.comment.startsWith('"') &&
tag.comment.endsWith('"')
) {
// string with double quotes
jsDocTags[tagName] = tag.comment.slice(1, -1);
} else if (tag.comment) {
// string without quotes
jsDocTags[tagName] = tag.comment;
if (tag.comment) {
try {
// Attempt to parse as JSON
const parsedValue = JSON.parse(tag.comment);
jsDocTags[tagName] = parsedValue;
} catch (e) {
// If JSON parsing fails, handle as before
jsDocTags[tagName] = tag.comment;
}
}
break;
case "strict":
Expand Down Expand Up @@ -363,7 +355,11 @@ export function jsDocTagToZodProperties(
: [f.createNumericLiteral(jsDocTags.default)]
: jsDocTags.default === null
? [f.createNull()]
: [f.createStringLiteral(jsDocTags.default)],
: Array.isArray(jsDocTags.default)
? [createArrayLiteralExpression(jsDocTags.default)]
: typeof jsDocTags.default === "object"
? [createObjectLiteralExpression(jsDocTags.default)]
: [f.createStringLiteral(String(jsDocTags.default))],
});
}

Expand Down Expand Up @@ -494,3 +490,61 @@ function withErrorMessage(expression: ts.Expression, errorMessage?: string) {
}
return [expression];
}

// Helper function to create an array literal expression
function createArrayLiteralExpression(
arr: JsonValue[]
): ts.ArrayLiteralExpression {
const elements = arr.map((item) => {
if (typeof item === "string") return f.createStringLiteral(item);
if (typeof item === "number") return f.createNumericLiteral(item);
if (typeof item === "boolean")
return item ? f.createTrue() : f.createFalse();
if (item === null) return f.createNull();
if (Array.isArray(item)) return createArrayLiteralExpression(item);
if (typeof item === "object") return createObjectLiteralExpression(item);
return f.createStringLiteral(String(item));
});
return f.createArrayLiteralExpression(elements);
}

// Helper function to create an object literal expression
function createObjectLiteralExpression(
obj: Record<string, JsonValue>
): ts.ObjectLiteralExpression {
const properties = Object.entries(obj).map(([key, value]) => {
const propertyName = f.createStringLiteral(key);
if (typeof value === "string")
return f.createPropertyAssignment(
propertyName,
f.createStringLiteral(value)
);
if (typeof value === "number")
return f.createPropertyAssignment(
propertyName,
f.createNumericLiteral(value)
);
if (typeof value === "boolean")
return f.createPropertyAssignment(
propertyName,
value ? f.createTrue() : f.createFalse()
);
if (value === null)
return f.createPropertyAssignment(propertyName, f.createNull());
if (Array.isArray(value))
return f.createPropertyAssignment(
propertyName,
createArrayLiteralExpression(value)
);
if (typeof value === "object")
return f.createPropertyAssignment(
propertyName,
createObjectLiteralExpression(value)
);
return f.createPropertyAssignment(
propertyName,
f.createStringLiteral(String(value))
);
});
return f.createObjectLiteralExpression(properties);
}

0 comments on commit 476180f

Please sign in to comment.