From 4899e4d43e78d78cafec0857f53670243c3493a7 Mon Sep 17 00:00:00 2001 From: Markus Blomqvist Date: Wed, 26 Nov 2025 01:05:15 +0200 Subject: [PATCH] feat: migrate to Zod v4 and harden schema tooling - upgrade workspace to Zod 4.1.13 and refresh the lockfile - fix schema detection, JSON conversion, and middleware handling regressions - add coverage for shared schema helpers plus app/router and RPC edge cases Co-authored-by: Julien Lestavel --- .github/workflows/ci.yml | 2 +- apps/example/package.json | 2 +- apps/example/public/openapi.json | 75 ++++++------- package.json | 2 +- packages/next-rest-framework/package.json | 7 +- .../src/app-router/route-operation.ts | 8 +- .../src/app-router/route.ts | 32 ++++-- .../src/app-router/rpc-route.ts | 22 ++-- .../src/pages-router/api-route-operation.ts | 8 +- .../src/pages-router/api-route.ts | 8 +- .../src/pages-router/rpc-api-route.ts | 26 +++-- .../next-rest-framework/src/shared/paths.ts | 10 +- .../src/shared/rpc-operation.ts | 12 +- .../next-rest-framework/src/shared/schemas.ts | 104 +++++++++++++----- packages/next-rest-framework/src/types.ts | 12 +- .../tests/app-router/route.test.ts | 52 +++++++++ .../tests/app-router/rpc-route.test.ts | 22 ++++ .../tests/pages-router/api-route.test.ts | 49 +++++++++ .../tests/pages-router/rpc-api-route.test.ts | 23 ++++ .../tests/shared/schemas.test.ts | 83 ++++++++++++++ packages/next-rest-framework/tests/utils.ts | 15 ++- pnpm-lock.yaml | 67 +++++------ 22 files changed, 477 insertions(+), 164 deletions(-) create mode 100644 packages/next-rest-framework/tests/shared/schemas.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e6f015..36062a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: - uses: actions/checkout@v3 - uses: pnpm/action-setup@v2 with: - version: 7 + version: 9 - uses: actions/setup-node@v3 with: node-version: ${{ matrix.node }} diff --git a/apps/example/package.json b/apps/example/package.json index 885e8be..72ef027 100644 --- a/apps/example/package.json +++ b/apps/example/package.json @@ -16,7 +16,7 @@ "jsdom": "24.0.0", "next-rest-framework": "workspace:*", "tsx": "4.7.2", - "zod-form-data": "2.0.2" + "zod-form-data": "3.0.1" }, "devDependencies": { "@types/jsdom": "^21.1.6", diff --git a/apps/example/public/openapi.json b/apps/example/public/openapi.json index da37333..6fa2105 100644 --- a/apps/example/public/openapi.json +++ b/apps/example/public/openapi.json @@ -998,19 +998,18 @@ "components": { "schemas": { "CreateTodo201ResponseBody": { - "type": "string", - "description": "New TODO created message." + "description": "New TODO created message.", + "type": "string" }, "CreateTodo401ResponseBody": { - "type": "string", - "description": "Unauthorized." + "description": "Unauthorized.", + "type": "string" }, "CreateTodoRequestBody": { + "description": "New TODO's name.", "type": "object", "properties": { "name": { "type": "string" } }, - "required": ["name"], - "additionalProperties": false, - "description": "New TODO's name." + "required": ["name"] }, "CreateTodoResponseBody": { "type": "object", @@ -1026,18 +1025,18 @@ "DeleteTodo404ResponseBody": { "type": "string" }, "DeleteTodoRequestBody": { "type": "string" }, "DeleteTodoResponseBody": { + "description": "TODO not found.", "type": "object", "properties": { "error": { "type": "string" } }, "required": ["error"], - "additionalProperties": false, - "description": "TODO not found." + "additionalProperties": false }, "DeleteTodoResponseBody2": { + "description": "TODO deleted message.", "type": "object", "properties": { "message": { "type": "string" } }, "required": ["message"], - "additionalProperties": false, - "description": "TODO deleted message." + "additionalProperties": false }, "ErrorMessage": { "type": "object", @@ -1058,40 +1057,40 @@ "format": "binary" }, "FormDataUrlEncodedRequestBody": { + "description": "Test form description.", "type": "object", "properties": { "text": { "type": "string" } }, - "required": ["text"], - "additionalProperties": false, - "description": "Test form description." + "required": ["text"] }, "FormDataUrlEncodedResponseBody": { + "description": "Test form response.", "type": "object", "properties": { "text": { "type": "string" } }, "required": ["text"], - "additionalProperties": false, - "description": "Test form response." + "additionalProperties": false }, "GetParams200ResponseBody": { + "description": "Parameters response.", "type": "object", "properties": { "slug": { "type": "string", "enum": ["foo", "bar", "baz"] }, "total": { "type": "string" } }, "required": ["slug", "total"], - "additionalProperties": false, - "description": "Parameters response." + "additionalProperties": false }, "GetPathParams200ResponseBody": { + "description": "Parameters response.", "type": "object", "properties": { "slug": { "type": "string", "enum": ["foo", "bar", "baz"] }, "total": { "type": "string" } }, "required": ["slug", "total"], - "additionalProperties": false, - "description": "Parameters response." + "additionalProperties": false }, "GetTodoById200ResponseBody": { + "description": "TODO response.", "type": "object", "properties": { "id": { "type": "number" }, @@ -1099,25 +1098,25 @@ "completed": { "type": "boolean" } }, "required": ["id", "name", "completed"], - "additionalProperties": false, - "description": "TODO response." + "additionalProperties": false }, "GetTodoById404ResponseBody": { - "type": "string", - "description": "TODO not found." + "description": "TODO not found.", + "type": "string" }, "GetTodoByIdRequestBody": { - "type": "string", - "description": "TODO name." + "description": "TODO name.", + "type": "string" }, "GetTodoByIdResponseBody": { + "description": "TODO not found.", "type": "object", "properties": { "error": { "type": "string" } }, "required": ["error"], - "additionalProperties": false, - "description": "TODO not found." + "additionalProperties": false }, "GetTodoByIdResponseBody2": { + "description": "TODO response.", "type": "object", "properties": { "id": { "type": "number" }, @@ -1125,10 +1124,10 @@ "completed": { "type": "boolean" } }, "required": ["id", "name", "completed"], - "additionalProperties": false, - "description": "TODO response." + "additionalProperties": false }, "GetTodos200ResponseBody": { + "description": "List of TODOs.", "type": "array", "items": { "type": "object", @@ -1139,10 +1138,10 @@ }, "required": ["id", "name", "completed"], "additionalProperties": false - }, - "description": "List of TODOs." + } }, "GetTodosResponseBody": { + "description": "List of TODOs.", "type": "array", "items": { "type": "object", @@ -1153,8 +1152,7 @@ }, "required": ["id", "name", "completed"], "additionalProperties": false - }, - "description": "List of TODOs." + } }, "MessageWithErrors": { "type": "object", @@ -1204,18 +1202,17 @@ }, "RouteWithExternalDep200ResponseBody": { "type": "string" }, "UrlEncodedFormData200ResponseBody": { + "description": "Test form response.", "type": "object", "properties": { "text": { "type": "string" } }, "required": ["text"], - "additionalProperties": false, - "description": "Test form response." + "additionalProperties": false }, "UrlEncodedFormDataRequestBody": { + "description": "Test form description.", "type": "object", "properties": { "text": { "type": "string" } }, - "required": ["text"], - "additionalProperties": false, - "description": "Test form description." + "required": ["text"] } } } diff --git a/package.json b/package.json index cd9f425..9c37115 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "next": "15.1.6", "prettier": "3.0.2", "typescript": "5.2.2", - "zod": "3.22.2" + "zod": "^4.1.13" }, "prettier": { "semi": true, diff --git a/packages/next-rest-framework/package.json b/packages/next-rest-framework/package.json index 1d579a8..1f1134c 100644 --- a/packages/next-rest-framework/package.json +++ b/packages/next-rest-framework/package.json @@ -41,8 +41,7 @@ "formidable": "^3.5.1", "lodash": "4.17.21", "prettier": "3.0.2", - "qs": "6.11.2", - "zod-to-json-schema": "3.21.4" + "qs": "6.11.2" }, "devDependencies": { "@types/formidable": "^3.4.5", @@ -58,7 +57,7 @@ "ts-node": "10.9.1", "tsup": "8.0.1", "typescript": "*", - "zod": "*", - "zod-form-data": "*" + "zod": "^4.1.13", + "zod-form-data": "3.0.1" } } diff --git a/packages/next-rest-framework/src/app-router/route-operation.ts b/packages/next-rest-framework/src/app-router/route-operation.ts index 5c4013f..83fee48 100644 --- a/packages/next-rest-framework/src/app-router/route-operation.ts +++ b/packages/next-rest-framework/src/app-router/route-operation.ts @@ -17,7 +17,7 @@ import { type ContentTypesThatSupportInputValidation } from '../types'; import { NextResponse, type NextRequest } from 'next/server'; -import { type ZodSchema, type z } from 'zod'; +import { type ZodType, type z } from 'zod'; import { type ValidMethod } from '../constants'; import { type I18NConfig } from 'next/dist/server/config-shared'; import { type NextURL } from 'next/dist/server/web/next-url'; @@ -200,14 +200,14 @@ interface InputObject< body?: ContentType extends ContentTypesThatSupportInputValidation ? ContentType extends FormDataContentType ? ZodFormSchema - : ZodSchema + : ZodType : never; /*! If defined, this will override the body schema for the OpenAPI spec. */ bodySchema?: OpenAPIV3_1.SchemaObject | OpenAPIV3_1.ReferenceObject; - query?: ZodSchema; + query?: ZodType; /*! If defined, this will override the query schema for the OpenAPI spec. */ querySchema?: OpenAPIV3_1.SchemaObject | OpenAPIV3_1.ReferenceObject; - params?: ZodSchema; + params?: ZodType; /*! If defined, this will override the params schema for the OpenAPI spec. */ paramsSchema?: OpenAPIV3_1.SchemaObject | OpenAPIV3_1.ReferenceObject; } diff --git a/packages/next-rest-framework/src/app-router/route.ts b/packages/next-rest-framework/src/app-router/route.ts index 8427aa3..20b7669 100644 --- a/packages/next-rest-framework/src/app-router/route.ts +++ b/packages/next-rest-framework/src/app-router/route.ts @@ -65,7 +65,11 @@ export const route = >( let middlewareOptions: BaseOptions = {}; if (middleware1) { - const res = await middleware1(reqClone, {...context, params: await context.params}, middlewareOptions); + const res = await middleware1( + reqClone, + { ...context, params: await context.params }, + middlewareOptions + ); const isOptionsResponse = (res: unknown): res is BaseOptions => typeof res === 'object'; @@ -77,7 +81,11 @@ export const route = >( } if (middleware2) { - const res2 = await middleware2(reqClone, {...context, params: await context.params}, middlewareOptions); + const res2 = await middleware2( + reqClone, + { ...context, params: await context.params }, + middlewareOptions + ); if (res2 instanceof Response) { return res2; @@ -88,7 +96,7 @@ export const route = >( if (middleware3) { const res3 = await middleware3( reqClone, - {...context, params: await context.params}, + { ...context, params: await context.params }, middlewareOptions ); @@ -165,16 +173,16 @@ export const route = >( try { const formData = await reqClone.formData(); - const { valid, errors, data } = validateSchema({ + const result = validateSchema({ schema: bodySchema, obj: formData }); - if (!valid) { + if (!result.valid) { return NextResponse.json( { message: DEFAULT_ERRORS.invalidRequestBody, - errors + errors: result.errors }, { status: 400 @@ -186,14 +194,16 @@ export const route = >( reqClone = new NextRequest(reqClone.url, { method: reqClone.method, headers: reqClone.headers, - body: JSON.stringify(data) + body: JSON.stringify(result.data) }); // Return parsed form data. reqClone.formData = async () => { const formData = new FormData(); - for (const [key, value] of Object.entries(data)) { + for (const [key, value] of Object.entries( + result.data as Record + )) { formData.append(key, value as string | Blob); } @@ -239,7 +249,7 @@ export const route = >( url.searchParams.delete(key); if (data[key]) { - url.searchParams.append(key, data[key]); + url.searchParams.append(key, data[key] as string); } }); @@ -268,13 +278,13 @@ export const route = >( ); } - context.params = data; + context.params = Promise.resolve(data); } } const res = await handler?.( reqClone as TypedNextRequest, - {...context, params: await context.params}, + { ...context, params: await context.params }, middlewareOptions ); diff --git a/packages/next-rest-framework/src/app-router/rpc-route.ts b/packages/next-rest-framework/src/app-router/rpc-route.ts index b3d40e5..5229bd5 100644 --- a/packages/next-rest-framework/src/app-router/rpc-route.ts +++ b/packages/next-rest-framework/src/app-router/rpc-route.ts @@ -88,13 +88,19 @@ export const rpcRoute = < let middlewareOptions: BaseOptions = {}; if (middleware1) { - middlewareOptions = await middleware1(body, middlewareOptions); + const res1 = await middleware1(body, middlewareOptions); + if (res1 && typeof res1 === 'object') + middlewareOptions = res1 as BaseOptions; if (middleware2) { - middlewareOptions = await middleware2(body, middlewareOptions); + const res2 = await middleware2(body, middlewareOptions); + if (res2 && typeof res2 === 'object') + middlewareOptions = res2 as BaseOptions; if (middleware3) { - middlewareOptions = await middleware3(body, middlewareOptions); + const res3 = await middleware3(body, middlewareOptions); + if (res3 && typeof res3 === 'object') + middlewareOptions = res3 as BaseOptions; } } } @@ -147,16 +153,16 @@ export const rpcRoute = < ) ) { try { - const { valid, errors, data } = validateSchema({ + const result = validateSchema({ schema: bodySchema, obj: body }); - if (!valid) { + if (!result.valid) { return NextResponse.json( { message: DEFAULT_ERRORS.invalidRequestBody, - errors + errors: result.errors }, { status: 400 @@ -166,7 +172,9 @@ export const rpcRoute = < const formData = new FormData(); - for (const [key, value] of Object.entries(data)) { + for (const [key, value] of Object.entries( + result.data as Record + )) { formData.append(key, value as string | Blob); } diff --git a/packages/next-rest-framework/src/pages-router/api-route-operation.ts b/packages/next-rest-framework/src/pages-router/api-route-operation.ts index 5c403d9..7f16a60 100644 --- a/packages/next-rest-framework/src/pages-router/api-route-operation.ts +++ b/packages/next-rest-framework/src/pages-router/api-route-operation.ts @@ -18,7 +18,7 @@ import { type BaseParams } from '../types'; import { type NextApiRequest, type NextApiResponse } from 'next/types'; -import { type ZodSchema, type z } from 'zod'; +import { type ZodType, type z } from 'zod'; export type TypedNextApiRequest< Method = keyof typeof ValidMethod, @@ -135,14 +135,14 @@ interface InputObject< body?: ContentType extends ContentTypesThatSupportInputValidation ? ContentType extends FormDataContentType ? ZodFormSchema - : ZodSchema + : ZodType : never; /*! If defined, this will override the body schema for the OpenAPI spec. */ bodySchema?: OpenAPIV3_1.SchemaObject | OpenAPIV3_1.ReferenceObject; - query?: ZodSchema; + query?: ZodType; /*! If defined, this will override the query schema for the OpenAPI spec. */ querySchema?: OpenAPIV3_1.SchemaObject | OpenAPIV3_1.ReferenceObject; - params?: ZodSchema; + params?: ZodType; /*! If defined, this will override the params schema for the OpenAPI spec. */ paramsSchema?: OpenAPIV3_1.SchemaObject | OpenAPIV3_1.ReferenceObject; } diff --git a/packages/next-rest-framework/src/pages-router/api-route.ts b/packages/next-rest-framework/src/pages-router/api-route.ts index 59d6368..4825ac7 100644 --- a/packages/next-rest-framework/src/pages-router/api-route.ts +++ b/packages/next-rest-framework/src/pages-router/api-route.ts @@ -176,9 +176,11 @@ export const apiRoute = >( const formData = new FormData(); - Object.entries(data).forEach(([key, value]) => { - formData.append(key, value as string | Blob); - }); + Object.entries(data as Record).forEach( + ([key, value]) => { + formData.append(key, value as string | Blob); + } + ); req.body = formData; } catch { diff --git a/packages/next-rest-framework/src/pages-router/rpc-api-route.ts b/packages/next-rest-framework/src/pages-router/rpc-api-route.ts index b9e3bf0..8cc9326 100644 --- a/packages/next-rest-framework/src/pages-router/rpc-api-route.ts +++ b/packages/next-rest-framework/src/pages-router/rpc-api-route.ts @@ -62,13 +62,19 @@ export const rpcApiRoute = < let middlewareOptions: BaseOptions = {}; if (middleware1) { - middlewareOptions = await middleware1(req.body, {}); + const res1 = await middleware1(req.body, {}); + if (res1 && typeof res1 === 'object') + middlewareOptions = res1 as BaseOptions; if (middleware2) { - middlewareOptions = await middleware2(req.body, middlewareOptions); + const res2 = await middleware2(req.body, middlewareOptions); + if (res2 && typeof res2 === 'object') + middlewareOptions = res2 as BaseOptions; if (middleware3) { - middlewareOptions = await middleware3(req.body, middlewareOptions); + const res3 = await middleware3(req.body, middlewareOptions); + if (res3 && typeof res3 === 'object') + middlewareOptions = res3 as BaseOptions; } } } @@ -128,15 +134,15 @@ export const rpcApiRoute = < } try { - const { valid, errors, data } = validateSchema({ + const result = validateSchema({ schema: bodySchema, obj: req.body }); - if (!valid) { + if (!result.valid) { res.status(400).json({ message: DEFAULT_ERRORS.invalidRequestBody, - errors + errors: result.errors }); return; @@ -144,9 +150,11 @@ export const rpcApiRoute = < const formData = new FormData(); - Object.entries(data).forEach(([key, value]) => { - formData.append(key, value as string | Blob); - }); + Object.entries(result.data as Record).forEach( + ([key, value]) => { + formData.append(key, value as string | Blob); + } + ); req.body = formData; } catch { diff --git a/packages/next-rest-framework/src/shared/paths.ts b/packages/next-rest-framework/src/shared/paths.ts index 232f03f..36e6228 100644 --- a/packages/next-rest-framework/src/shared/paths.ts +++ b/packages/next-rest-framework/src/shared/paths.ts @@ -12,7 +12,7 @@ import { import { merge } from 'lodash'; import { getJsonSchema } from './schemas'; -import { type ZodObject, type ZodSchema, type ZodRawShape } from 'zod'; +import { type ZodObject, type ZodType, type ZodRawShape } from 'zod'; import { type ApiRouteOperationDefinition } from '../pages-router'; import { type RouteOperationDefinition } from '../app-router'; import { type RpcOperationDefinition } from './rpc-operation'; @@ -112,7 +112,7 @@ export const getPathsFromRoute = ({ }; const description = - input.bodySchema?.description ?? input.body._def.description; + input.bodySchema?.description ?? input.body.description; if (description) { generatedOperationObject.requestBody.description = description; @@ -185,7 +185,7 @@ export const getPathsFromRoute = ({ const description = bodySchema?.description ?? - body._def.description ?? + body.description ?? `Response for status ${status}`; return Object.assign(obj, { @@ -219,7 +219,7 @@ export const getPathsFromRoute = ({ pathParameters = Object.entries(schema).map(([name, schema]) => { const _schema = (input.params as ZodObject).shape[ name - ] as ZodSchema; + ] as ZodType; // eslint-disable-next-line @typescript-eslint/consistent-type-assertions return { @@ -269,7 +269,7 @@ export const getPathsFromRoute = ({ ...Object.entries(schema).map(([name, schema]) => { const _schema = (input.query as ZodObject).shape[ name - ] as ZodSchema; + ] as ZodType; // eslint-disable-next-line @typescript-eslint/consistent-type-assertions return { diff --git a/packages/next-rest-framework/src/shared/rpc-operation.ts b/packages/next-rest-framework/src/shared/rpc-operation.ts index e04d1c2..6baf4c7 100644 --- a/packages/next-rest-framework/src/shared/rpc-operation.ts +++ b/packages/next-rest-framework/src/shared/rpc-operation.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-invalid-void-type */ -import { type z, type ZodSchema } from 'zod'; +import { type z, type ZodType } from 'zod'; import { validateSchema } from './schemas'; import { DEFAULT_ERRORS } from '../constants'; import { @@ -21,12 +21,12 @@ interface InputObject { contentType?: ContentType; body?: ContentType extends FormDataContentType ? ZodFormSchema - : ZodSchema; + : ZodType; /*! If defined, this will override the body schema for the OpenAPI spec. */ bodySchema?: OpenAPIV3_1.SchemaObject | OpenAPIV3_1.ReferenceObject; } interface OutputObject { - body: ZodSchema; + body: ZodType; /*! If defined, this will override the body schema for the OpenAPI spec. */ bodySchema?: OpenAPIV3_1.SchemaObject | OpenAPIV3_1.ReferenceObject; contentType: BaseContentType; @@ -49,7 +49,7 @@ type RpcOperationHandler< > = ( params: ContentType extends FormDataContentType ? TypedFormData>> - : z.infer>, + : z.infer>, options: Options ) => | Promise> @@ -75,7 +75,7 @@ export type RpcOperationDefinition< ? ( body: ContentType extends FormDataContentType ? FormData - : z.infer> + : z.infer> ) => TypedResponse : () => TypedResponse) & { _meta: OperationDefinitionMeta; @@ -185,7 +185,7 @@ export const rpcOperation = (openApiOperation?: OpenApiOperation) => { const operation = async ( body: ContentType extends FormDataContentType ? FormData - : z.infer> + : z.infer> ) => await callOperation(body); operation._meta = meta; diff --git a/packages/next-rest-framework/src/shared/schemas.ts b/packages/next-rest-framework/src/shared/schemas.ts index c521fc8..7e091eb 100644 --- a/packages/next-rest-framework/src/shared/schemas.ts +++ b/packages/next-rest-framework/src/shared/schemas.ts @@ -1,39 +1,53 @@ import { type OpenAPIV3_1 } from 'openapi-types'; -import { zodToJsonSchema } from 'zod-to-json-schema'; -import { type AnyZodObject, type ZodSchema } from 'zod'; +import { z, type ZodType, type ZodTypeAny } from 'zod'; import { type zfd } from 'zod-form-data'; import chalk from 'chalk'; -const isZodSchema = (schema: unknown): schema is ZodSchema => +const isZodSchema = (schema: unknown): schema is ZodTypeAny => !!schema && typeof schema === 'object' && '_def' in schema; -const isZodObjectSchema = (schema: unknown): schema is AnyZodObject => - isZodSchema(schema) && 'shape' in schema; +const isZodObjectSchema = (schema: unknown): schema is z.ZodObject => + schema instanceof z.ZodObject; -const zodSchemaValidator = ({ +const isZodArraySchema = ( + schema: ZodTypeAny +): schema is z.ZodArray => schema instanceof z.ZodArray; + +const zodSchemaValidator = ({ schema, obj }: { - schema: ZodSchema; + schema: ZodType; obj: unknown; -}) => { - const data = schema.safeParse(obj); - const errors = !data.success ? data.error.issues : null; +}): + | { valid: true; errors: null; data: T } + | { valid: false; errors: z.ZodIssue[]; data: null } => { + const result = schema.safeParse(obj); + + if (result.success) { + return { + valid: true, + errors: null, + data: result.data + }; + } return { - valid: data.success, - errors, - data: data.success ? data.data : null + valid: false, + errors: result.error.issues, + data: null }; }; -export const validateSchema = ({ +export const validateSchema = ({ schema, obj }: { - schema: ZodSchema | typeof zfd.formData; + schema: ZodType | typeof zfd.formData; obj: unknown; -}) => { +}): + | { valid: true; errors: null; data: T } + | { valid: false; errors: z.ZodIssue[]; data: null } => { if (isZodSchema(schema)) { return zodSchemaValidator({ schema, obj }); } @@ -43,21 +57,58 @@ export const validateSchema = ({ type SchemaType = 'input-params' | 'input-query' | 'input-body' | 'output-body'; +// Helper function to register descriptions in a local Zod registry +const registerDescriptions = ( + schema: ZodTypeAny, + registry: z.core.$ZodRegistry<{ description?: string }> +): void => { + // Register description for the schema itself + if (schema.description) { + registry.add(schema, { description: schema.description }); + } + + // For object schemas, register descriptions for each property + if (isZodObjectSchema(schema)) { + Object.values(schema.shape as Record).forEach( + (propSchema) => { + registerDescriptions(propSchema, registry); + } + ); + } + + // For array schemas, register description for the element type + if (isZodArraySchema(schema)) { + registerDescriptions(schema.element, registry); + } +}; + export const getJsonSchema = ({ schema, operationId, type }: { - schema: ZodSchema; + schema: ZodType; operationId: string; type: SchemaType; }): OpenAPIV3_1.SchemaObject => { if (isZodSchema(schema)) { try { - return zodToJsonSchema(schema, { - $refStrategy: 'none', - target: 'openApi3' - }); + // Create a local registry to avoid global registry conflicts (see: https://github.com/colinhacks/zod/issues/4145) + const localRegistry = z.core.registry<{ description?: string }>(); + + // Register descriptions in the local registry so toJSONSchema can access them + registerDescriptions(schema, localRegistry); + + // For input schemas, use 'input' to get what the API accepts (before transformations) + // For output schemas, use 'output' to get what the API returns (after transformations) + const io = type === 'output-body' ? 'output' : 'input'; + + return z.toJSONSchema(schema, { + target: 'openapi-3.0', + unrepresentable: 'any', // Allow unrepresentable types (date, bigint, etc.) to be converted to {} + io, + metadata: localRegistry + }) as OpenAPIV3_1.SchemaObject; } catch (error) { const solutions: Record = { 'input-params': 'paramsSchema', @@ -70,8 +121,11 @@ export const getJsonSchema = ({ chalk.yellowBright( ` Warning: ${type} schema for operation ${operationId} could not be converted to a JSON schema. The OpenAPI spec may not be accurate. -This is most likely related to an issue with the \`zod-to-json-schema\`: https://github.com/StefanTerdell/zod-to-json-schema?tab=readme-ov-file#known-issues -Please consider using the ${solutions[type]} property in addition to the Zod schema.` +Error: ${error instanceof Error ? error.message : String(error)} +This is most likely related to Zod v4's toJSONSchema() limitations or an issue with the schema structure. +Please consider using the ${ + solutions[type] + } property in addition to the Zod schema.` ) ); @@ -82,9 +136,9 @@ Please consider using the ${solutions[type]} property in addition to the Zod sch throw Error('Invalid schema.'); }; -export const getSchemaKeys = ({ schema }: { schema: ZodSchema }) => { +export const getSchemaKeys = ({ schema }: { schema: ZodType }) => { if (isZodObjectSchema(schema)) { - return Object.keys(schema._def.shape()); + return Object.keys(schema.shape); } throw Error('Invalid schema.'); diff --git a/packages/next-rest-framework/src/types.ts b/packages/next-rest-framework/src/types.ts index f4801ad..f443cdd 100644 --- a/packages/next-rest-framework/src/types.ts +++ b/packages/next-rest-framework/src/types.ts @@ -1,5 +1,5 @@ import { type OpenAPIV3_1 } from 'openapi-types'; -import { type ZodEffects, type z, type ZodSchema } from 'zod'; +import { type ZodType, type ZodTransform, type ZodPipe } from 'zod'; export type DocsProvider = 'redoc' | 'swagger-ui'; @@ -86,7 +86,7 @@ export interface OutputObject< ContentType extends AnyContentTypeWithAutocompleteForMostCommonOnes = AnyContentTypeWithAutocompleteForMostCommonOnes > { - body: ZodSchema; + body: ZodType; bodySchema?: | OpenAPIV3_1.SchemaObject | OpenAPIV3_1.ReferenceObject /*! If defined, this will override the body schema for the OpenAPI spec. */; @@ -203,8 +203,6 @@ interface FormDataLikeInput { entries: () => IterableIterator<[string, FormDataEntryValue]>; } -export type ZodFormSchema = ZodEffects< - ZodSchema, - z.output>, - FormData | FormDataLikeInput ->; +export type ZodFormSchema = + | ZodTransform + | ZodPipe>; diff --git a/packages/next-rest-framework/tests/app-router/route.test.ts b/packages/next-rest-framework/tests/app-router/route.test.ts index bd91270..003b002 100644 --- a/packages/next-rest-framework/tests/app-router/route.test.ts +++ b/packages/next-rest-framework/tests/app-router/route.test.ts @@ -3,6 +3,7 @@ import { TypedNextResponse, route, routeOperation } from '../../src/app-router'; import { DEFAULT_ERRORS, ValidMethod } from '../../src/constants'; import { createMockRouteRequest } from '../utils'; import { validateSchema } from '../../src/shared'; +import { getPathsFromRoute } from '../../src/shared/paths'; import { zfd } from 'zod-form-data'; describe('route', () => { @@ -675,4 +676,55 @@ describe('route', () => { expect(console.log).toHaveBeenCalledWith('baz'); expect(console.log).toHaveBeenCalledWith('handler'); }); + + it('preserves schema descriptions in OpenAPI spec', async () => { + const { req, context } = createMockRouteRequest({ + method: ValidMethod.GET + }); + + const schema = z.object({ + foo: z.string().describe('A test field'), + items: z + .array( + z.object({ + id: z.string().describe('Item ID'), + name: z.string().describe('Item name') + }) + ) + .describe('List of items') + }); + + const operations = { + test: routeOperation({ method: 'GET' }) + .outputs([ + { + status: 200, + contentType: 'application/json', + body: schema + } + ]) + .handler(() => + TypedNextResponse.json({ + foo: 'bar', + items: [{ id: '1', name: 'x' }] + }) + ) + } as const; + + await route(operations).GET(req, context); + + const { schemas } = getPathsFromRoute({ + operations, + route: '/api/test' + }); + + const responseSchema = schemas?.Test200ResponseBody as any; + expect(responseSchema?.properties?.foo?.description).toBe('A test field'); + expect(responseSchema?.properties?.items?.description).toBe( + 'List of items' + ); + const itemsSchema = responseSchema?.properties?.items; + expect(itemsSchema?.items?.properties?.id?.description).toBe('Item ID'); + expect(itemsSchema?.items?.properties?.name?.description).toBe('Item name'); + }); }); diff --git a/packages/next-rest-framework/tests/app-router/rpc-route.test.ts b/packages/next-rest-framework/tests/app-router/rpc-route.test.ts index fe488da..6043090 100644 --- a/packages/next-rest-framework/tests/app-router/rpc-route.test.ts +++ b/packages/next-rest-framework/tests/app-router/rpc-route.test.ts @@ -441,4 +441,26 @@ describe('rpcRoute', () => { expect(console.log).toHaveBeenCalledWith('baz'); expect(console.log).toHaveBeenCalledWith('handler'); }); + + it('ignores non-object middleware return values when building options', async () => { + const { req, context } = createMockRpcRouteRequest({ + body: { foo: 'bar' }, + headers: { 'content-type': 'application/json' } + }); + + const res = await rpcRoute({ + test: rpcOperation() + .input({ + contentType: 'application/json', + body: z.object({ foo: z.string() }) + }) + .middleware(() => 'noop' as any) + .middleware(() => undefined) + .middleware(() => 42 as any) + .handler((_input, options) => options) + }).POST(req, context); + + const json = await res?.json(); + expect(json).toEqual({}); + }); }); diff --git a/packages/next-rest-framework/tests/pages-router/api-route.test.ts b/packages/next-rest-framework/tests/pages-router/api-route.test.ts index 6a1a53d..de62701 100644 --- a/packages/next-rest-framework/tests/pages-router/api-route.test.ts +++ b/packages/next-rest-framework/tests/pages-router/api-route.test.ts @@ -4,6 +4,7 @@ import { validateSchema } from '../../src/shared'; import { createMockApiRouteRequest } from '../utils'; import { apiRoute, apiRouteOperation } from '../../src/pages-router'; import { zfd } from 'zod-form-data'; +import { getPathsFromRoute } from '../../src/shared/paths'; describe('apiRoute', () => { it.each(Object.values(ValidMethod))( @@ -655,4 +656,52 @@ describe('apiRoute', () => { expect(console.log).toHaveBeenCalledWith('baz'); expect(console.log).toHaveBeenCalledWith('handler'); }); + + it('preserves schema descriptions in OpenAPI spec', async () => { + const { req, res } = createMockApiRouteRequest({ + method: ValidMethod.GET + }); + + const schema = z.object({ + foo: z.string().describe('A test field'), + items: z + .array( + z.object({ + id: z.string().describe('Item ID'), + name: z.string().describe('Item name') + }) + ) + .describe('List of items') + }); + + const operations = { + test: apiRouteOperation({ method: 'GET' }) + .outputs([ + { + status: 200, + contentType: 'application/json', + body: schema + } + ]) + .handler((_req, res) => { + res.json({ foo: 'bar', items: [{ id: '1', name: 'Test' }] }); + }) + }; + + await apiRoute(operations)(req, res); + + const { schemas } = getPathsFromRoute({ + operations, + route: '/api/test' + }); + + const responseSchema = schemas?.Test200ResponseBody; + expect(responseSchema?.properties?.foo?.description).toBe('A test field'); + expect(responseSchema?.properties?.items?.description).toBe( + 'List of items' + ); + const itemsSchema = responseSchema?.properties?.items as any; + expect(itemsSchema?.items?.properties?.id?.description).toBe('Item ID'); + expect(itemsSchema?.items?.properties?.name?.description).toBe('Item name'); + }); }); diff --git a/packages/next-rest-framework/tests/pages-router/rpc-api-route.test.ts b/packages/next-rest-framework/tests/pages-router/rpc-api-route.test.ts index ff427ec..0386ff5 100644 --- a/packages/next-rest-framework/tests/pages-router/rpc-api-route.test.ts +++ b/packages/next-rest-framework/tests/pages-router/rpc-api-route.test.ts @@ -442,4 +442,27 @@ describe('rpcApiRoute', () => { expect(console.log).toHaveBeenCalledWith('baz'); expect(console.log).toHaveBeenCalledWith('handler'); }); + + it('ignores non-object middleware return values when building options', async () => { + const { req, res } = createMockRpcApiRouteRequest({ + body: { foo: 'bar' }, + headers: { + 'content-type': 'application/json' + } + }); + + await rpcApiRoute({ + test: rpcOperation() + .input({ + contentType: 'application/json', + body: z.object({ foo: z.string() }) + }) + .middleware(() => 'noop' as any) + .middleware(() => undefined) + .middleware(() => null as any) + .handler((_input, options) => options) + })(req, res); + + expect(res._getJSONData()).toEqual({}); + }); }); diff --git a/packages/next-rest-framework/tests/shared/schemas.test.ts b/packages/next-rest-framework/tests/shared/schemas.test.ts new file mode 100644 index 0000000..7002360 --- /dev/null +++ b/packages/next-rest-framework/tests/shared/schemas.test.ts @@ -0,0 +1,83 @@ +import { z } from 'zod'; +import { getJsonSchema, validateSchema } from '../../src/shared'; + +describe('shared/schemas', () => { + describe('validateSchema', () => { + it('returns parsed data for valid objects', () => { + const schema = z.object({ + foo: z.string(), + count: z.number().int() + }); + + const result = validateSchema({ + schema, + obj: { foo: 'bar', count: 3 } + }); + + expect(result).toEqual({ + valid: true, + errors: null, + data: { foo: 'bar', count: 3 } + }); + }); + + it('collects issues for invalid data', () => { + const schema = z.object({ + foo: z.string(), + count: z.number() + }); + + const result = validateSchema({ + schema, + obj: { foo: 123 } + }); + + expect(result.valid).toBe(false); + expect(result.data).toBeNull(); + expect(result.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: ['foo'] }), + expect.objectContaining({ path: ['count'] }) + ]) + ); + }); + }); + + describe('getJsonSchema', () => { + it('preserves nested descriptions for objects and arrays', () => { + const schema = z.object({ + foo: z.string().describe('Foo description'), + items: z + .array( + z + .object({ + id: z.string().describe('Item id'), + tags: z + .array(z.string().describe('Tag value')) + .describe('Tag list') + }) + .describe('Item object') + ) + .describe('Items list') + }); + + const jsonSchema = getJsonSchema({ + schema, + operationId: 'TestOperation', + type: 'output-body' + }); + + const properties = jsonSchema.properties as any; + expect(properties.foo.description).toBe('Foo description'); + + const itemsSchema = properties.items; + expect(itemsSchema.description).toBe('Items list'); + expect(itemsSchema.items.description).toBe('Item object'); + expect(itemsSchema.items.properties.id.description).toBe('Item id'); + expect(itemsSchema.items.properties.tags.description).toBe('Tag list'); + expect(itemsSchema.items.properties.tags.items.description).toBe( + 'Tag value' + ); + }); + }); +}); diff --git a/packages/next-rest-framework/tests/utils.ts b/packages/next-rest-framework/tests/utils.ts index 50eafa4..39f5b28 100644 --- a/packages/next-rest-framework/tests/utils.ts +++ b/packages/next-rest-framework/tests/utils.ts @@ -1,4 +1,4 @@ -import { type ZodSchema } from 'zod'; +import { type ZodTypeAny } from 'zod'; import { NextRequest } from 'next/server'; import { DEFAULT_DESCRIPTION, @@ -38,7 +38,7 @@ export const createMockRouteRequest = ({ headers?: Record; }): { req: NextRequest; - context: { params: typeof params }; + context: { params: Promise }; } => ({ req: new NextRequest(`http://localhost:3000${path}?${qs.stringify(query)}`, { method, @@ -52,7 +52,7 @@ export const createMockRouteRequest = ({ ...headers } }), - context: { params } + context: { params: Promise.resolve(params) } }); export const createMockRpcRouteRequest = ({ @@ -69,7 +69,7 @@ export const createMockRpcRouteRequest = ({ headers?: Record; } = {}): { req: NextRequest; - context: { params: { operationId: typeof operation } }; + context: { params: Promise<{ operationId: typeof operation }> }; } => { const { req } = createMockRouteRequest({ path, @@ -80,7 +80,10 @@ export const createMockRpcRouteRequest = ({ } }); - return { req, context: { params: { operationId: operation } } }; + return { + req, + context: { params: Promise.resolve({ operationId: operation }) } + }; }; export const createMockApiRouteRequest = < @@ -148,7 +151,7 @@ export const getExpectedSpec = ({ allowedPaths, deniedPaths }: { - zodSchema: ZodSchema; + zodSchema: ZodTypeAny; allowedPaths: string[]; deniedPaths: string[]; }) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5d902b..389d943 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -58,8 +58,8 @@ importers: specifier: 5.2.2 version: 5.2.2 zod: - specifier: 3.22.2 - version: 3.22.2 + specifier: ^4.1.13 + version: 4.1.13 apps/example: dependencies: @@ -73,8 +73,8 @@ importers: specifier: 4.7.2 version: 4.7.2 zod-form-data: - specifier: 2.0.2 - version: 2.0.2(zod@3.22.2) + specifier: 3.0.1 + version: 3.0.1(zod@4.1.13) devDependencies: '@types/jsdom': specifier: ^21.1.6 @@ -143,9 +143,6 @@ importers: qs: specifier: 6.11.2 version: 6.11.2 - zod-to-json-schema: - specifier: 3.21.4 - version: 3.21.4(zod@3.22.2) devDependencies: '@types/formidable': specifier: ^3.4.5 @@ -187,11 +184,11 @@ importers: specifier: '*' version: 5.2.2 zod: - specifier: '*' - version: 3.22.2 + specifier: ^4.1.13 + version: 4.1.13 zod-form-data: - specifier: '*' - version: 2.0.2(zod@3.22.2) + specifier: 3.0.1 + version: 3.0.1(zod@4.1.13) packages: @@ -442,6 +439,7 @@ packages: '@babel/plugin-proposal-object-rest-spread@7.12.1': resolution: {integrity: sha512-s6SowJIjzlhx8o7lsFx5zmY4At6CTtDvgNQDdPzkBQucle58A6b/TTeEBYtyDgmcXjUTM+vE8YOGHZzzbc/ioA==} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-object-rest-spread instead. peerDependencies: '@babel/core': ^7.0.0-0 @@ -1295,6 +1293,7 @@ packages: '@humanwhocodes/config-array@0.11.10': resolution: {integrity: sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ==} engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead '@humanwhocodes/module-importer@1.0.1': resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} @@ -1302,6 +1301,7 @@ packages: '@humanwhocodes/object-schema@1.2.1': resolution: {integrity: sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==} + deprecated: Use @eslint/object-schema instead '@img/sharp-darwin-arm64@0.33.5': resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==} @@ -1725,6 +1725,9 @@ packages: '@rushstack/eslint-patch@1.6.1': resolution: {integrity: sha512-UY+FGM/2jjMkzQLn8pxcHGMaVLh9aEitG3zY2CiY7XHdLiz3bZOwa6oDxNqEMv7zZkV+cj5DOdz0cQ1BP5Hjgw==} + '@rvf/set-get@7.0.1': + resolution: {integrity: sha512-GkTSn9K1GrTYoTUqlUs36k6nJnzjQaFBTTEIqUYmzBcsGsoJM8xG7EAx2WLHWAA4QzFjcwWUSHQ3vM3Fbw50Tg==} + '@sideway/address@4.1.4': resolution: {integrity: sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw==} @@ -2156,6 +2159,7 @@ packages: acorn-import-assertions@1.9.0: resolution: {integrity: sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==} + deprecated: package has been renamed to acorn-import-attributes peerDependencies: acorn: ^8 @@ -3226,6 +3230,7 @@ packages: eslint-config-standard-with-typescript@38.0.0: resolution: {integrity: sha512-G7JR6I8tmWEQjzbESo/9gVq4AQctbVO4J8PINQj8l2lgbJF/W9KCJ4uDLiKmLMjWszW/F5SsucoA/5VpHvfwOQ==} + deprecated: Please use eslint-config-love, instead. peerDependencies: '@typescript-eslint/eslint-plugin': ^6.1.0 eslint: ^8.0.1 @@ -3335,6 +3340,7 @@ packages: eslint@8.47.0: resolution: {integrity: sha512-spUQWrdPt+pRVP1TTJLmfRNJJHHZryFmptzcafwSvHsceV81djHOdnEeDmkdotZyLNjDhrOasNK8nikkoG1O8Q==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true espree@9.6.1: @@ -3639,9 +3645,11 @@ packages: glob@7.1.7: resolution: {integrity: sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==} + deprecated: Glob versions prior to v9 are no longer supported glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported global-dirs@3.0.1: resolution: {integrity: sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==} @@ -3901,6 +3909,7 @@ packages: inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. inherits@2.0.3: resolution: {integrity: sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==} @@ -5589,6 +5598,7 @@ packages: rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true rollup@4.9.4: @@ -6096,6 +6106,7 @@ packages: trim@0.0.1: resolution: {integrity: sha512-YzQV+TZg4AxpKxaTHK3c3D+kRDCGVEE7LemdlQZoQXn0iennk10RsIoY6ikzAqJTc9Xjl9C1/waHom/J86ziAQ==} + deprecated: Use String.prototype.trim() instead trough@1.0.5: resolution: {integrity: sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA==} @@ -6657,18 +6668,13 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - zod-form-data@2.0.2: - resolution: {integrity: sha512-sKTi+k0fvkxdakD0V5rq+9WVJA3cuTQUfEmNqvHrTzPLvjfLmkkBLfR0ed3qOi9MScJXTHIDH/jUNnEJ3CBX4g==} - peerDependencies: - zod: '>= 3.11.0' - - zod-to-json-schema@3.21.4: - resolution: {integrity: sha512-fjUZh4nQ1s6HMccgIeE0VP4QG/YRGPmyjO9sAh890aQKPEk3nqbfUXhMFaC+Dr5KvYBm8BCyvfpZf2jY9aGSsw==} + zod-form-data@3.0.1: + resolution: {integrity: sha512-uwSrDzpLDoXeAxePjPHrjjMelE5pk5zL5JcwLFISvqidGjtPl7hcheH584xGcS76c9IRHq6tqdGkf+A4eKO6Cw==} peerDependencies: - zod: ^3.21.4 + zod: '>= 3.25.0' - zod@3.22.2: - resolution: {integrity: sha512-wvWkphh5WQsJbVk1tbx1l1Ly4yg+XecD+Mq280uBGt9wa5BKSWf4Mhp6GmrkPixhMxmabYY7RbzlwVP32pbGCg==} + zod@4.1.13: + resolution: {integrity: sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==} zwitch@1.0.5: resolution: {integrity: sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==} @@ -8823,6 +8829,8 @@ snapshots: '@rushstack/eslint-patch@1.6.1': {} + '@rvf/set-get@7.0.1': {} + '@sideway/address@4.1.4': dependencies: '@hapi/hoek': 9.3.0 @@ -10623,7 +10631,7 @@ snapshots: debug: 4.3.4 enhanced-resolve: 5.15.0 eslint: 8.47.0 - eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.4.1(eslint@8.47.0)(typescript@5.2.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.4.1(eslint@8.47.0)(typescript@5.2.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.28.1)(eslint@8.47.0))(eslint@8.47.0) + eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.4.1(eslint@8.47.0)(typescript@5.2.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.47.0) eslint-plugin-import: 2.28.1(@typescript-eslint/parser@6.4.1(eslint@8.47.0)(typescript@5.2.2))(eslint-import-resolver-typescript@3.6.1)(eslint@8.47.0) fast-glob: 3.3.2 get-tsconfig: 4.7.2 @@ -10635,7 +10643,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.8.0(@typescript-eslint/parser@6.4.1(eslint@8.47.0)(typescript@5.2.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.4.1(eslint@8.47.0)(typescript@5.2.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.28.1)(eslint@8.47.0))(eslint@8.47.0): + eslint-module-utils@2.8.0(@typescript-eslint/parser@6.4.1(eslint@8.47.0)(typescript@5.2.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.47.0): dependencies: debug: 3.2.7 optionalDependencies: @@ -10662,7 +10670,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.47.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.4.1(eslint@8.47.0)(typescript@5.2.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.4.1(eslint@8.47.0)(typescript@5.2.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.28.1)(eslint@8.47.0))(eslint@8.47.0) + eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.4.1(eslint@8.47.0)(typescript@5.2.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.47.0) has: 1.0.3 is-core-module: 2.13.0 is-glob: 4.0.3 @@ -14616,14 +14624,11 @@ snapshots: yocto-queue@0.1.0: {} - zod-form-data@2.0.2(zod@3.22.2): - dependencies: - zod: 3.22.2 - - zod-to-json-schema@3.21.4(zod@3.22.2): + zod-form-data@3.0.1(zod@4.1.13): dependencies: - zod: 3.22.2 + '@rvf/set-get': 7.0.1 + zod: 4.1.13 - zod@3.22.2: {} + zod@4.1.13: {} zwitch@1.0.5: {}