zod-express-middleware icon indicating copy to clipboard operation
zod-express-middleware copied to clipboard

Default values from schema not applied to Request object

Open jstorm31 opened this issue 2 years ago • 8 comments

I have a validation schema for the limit parameter, which has a default value:

const limitParam = z.number().min(0).optional().default(10)

The middleware validates this parameter in the query, but it does not add default values to the parameters. Therefore, req.query still contains the original incoming object.

router.get(validateRequest(routeSchema.request), (req, res) => {
   console.log(req.query.limit) // -> undefined
})

This may be intentional, as the library is not intended for sanitization and default values.

jstorm31 avatar Jul 31 '23 11:07 jstorm31

I believe this is the desired behaviour for validateRequest* calls but not for processRequest* calls. validateRequest* does not modify the query, params and body whereas processRequest* does.

AngaBlue avatar Aug 11 '23 03:08 AngaBlue

Oh, I overlooked the processRequest* functions. Thank you! 🙌

jstorm31 avatar Aug 11 '23 07:08 jstorm31

I believe the types still don't function correctly though. It appears that the types are using the schema input types rather than the schema output types.

AngaBlue avatar Aug 11 '23 08:08 AngaBlue

Can confirm this is broken

with member default set

app.get(
        "/endpoint",
        processRequest({
            query: z.object({
                sort: z.enum(["NAME_ASC", "NAME_DSC"]).default("NAME_ASC"),
            }),
        }),
        async (request, response) => {
            const sort = request.query.sort; // "NAME_ASC" | "NAME_DSC" | undefined
        }
    );

without member default set

    app.get(
        "/endpoint",
        processRequest({
            query: z.object({
                sort: z.enum(["NAME_ASC", "NAME_DSC"]),
            }),
        }),
        async (request, response) => {
            const sort = request.query.sort; // "NAME_ASC" | "NAME_DSC"
        }
    );

with a default on the wrapping object. now the entire object could be undefined...

    app.get(
        "/endpoint",
        processRequest({
            query: z.object({
                sort: z.enum(["NAME_ASC", "NAME_DSC"]).default("NAME_ASC"),
            }).default({}),
        }),
        async (request, response) => {
            request.query // {...} | undefined
            request.query.sort // error as request.query may be undefined
            
        }
    );

even if the default is explicitly stated... still could be undefined.

    app.get(
        "/endpoint",
        processRequest({
            query: z.object({
                sort: z.enum(["NAME_ASC", "NAME_DSC"]).default("NAME_ASC"),
            }).default({
                sort: "NAME_ASC"
            }),
        }),
        async (request, response) => {
            request.query // {...} | undefined
            request.query.sort // error as request.query may be undefined
            
        }
    );

RobMayer avatar Aug 18 '23 18:08 RobMayer

I tested this again today, and processRequest seems to work fine for me with version 1.4.0

// GET /test
router.get('/test', processRequest(
query: z.object({
            limit: z.number().min(0).optional().default(10),
        })
)),
(req, res) => {
   req.query.limit // 10
}

In the library, if safeParse call is successful, they mutate the req.query object here

jstorm31 avatar Aug 21 '23 08:08 jstorm31

The issue is not the values, it's the inferred types which are incorrect.

AngaBlue avatar Aug 21 '23 09:08 AngaBlue

Oh I see, you're right, the type is still number | undefined. ☝️

jstorm31 avatar Aug 21 '23 09:08 jstorm31

I ended up writing my own middleware which addresses this issue. The usage is not the same, but regardless you may find it useful. I would submit a PR to fix this issue but it appears as though this project is unmaintained.

import { RequestHandler } from 'express';
import { ZodError, z } from 'zod';

const types = ['query', 'params', 'body'] as const;

/**
 * A middleware generator that validates incoming requests against a set of schemas.
 * @param schemas The schemas to validate against.
 * @returns A middleware function that validates the request.
 */
export default function validate<TParams extends Validation = {}, TQuery extends Validation = {}, TBody extends Validation = {}>(
    schemas: ValidationSchemas<TParams, TQuery, TBody>
): RequestHandler<ZodOutput<TParams>, any, ZodOutput<TBody>, ZodOutput<TQuery>> {
    // Create validation objects for each type
    const validation = {
        params: z.object(schemas.params ?? {}).strict() as z.ZodObject<TParams>,
        query: z.object(schemas.query ?? {}).strict() as z.ZodObject<TQuery>,
        body: z.object(schemas.body ?? {}).strict() as z.ZodObject<TBody>
    };

    return (req, res, next) => {
        const errors: Array<ErrorListItem> = [];

        // Validate all types (params, query, body)
        for (const type of types) {
            const parsed = validation[type].safeParse(req[type]);
            // @ts-expect-error This is fine
            if (parsed.success) req[type] = parsed.data;
            else errors.push({ type, errors: parsed.error });
        }

        // Return all errors if there are any
        if (errors.length > 0) return res.status(400).send(errors.map(error => ({ type: error.type, errors: error.errors })));

        return next();
    };
}

/**
 * The types of validation that can be performed.
 */
type DataType = (typeof types)[number];

/**
 * An error item for a specific type.
 */
interface ErrorListItem {
    type: DataType;
    errors: ZodError<any>;
}

/**
 * Generic validation type for a route (either params, query, or body).
 */
type Validation = Record<string, z.ZodTypeAny>;

/**
 * The schemas provided to the validate middleware.
 */
interface ValidationSchemas<TParams extends Validation, TQuery extends Validation, TBody extends Validation> {
    params?: TParams;
    query?: TQuery;
    body?: TBody;
}

/**
 * The output type of a validation schema.
 */
type ZodOutput<T extends Validation> = z.ZodObject<T>['_output'];

A really basic usage example looks like this:

app.post('/test', validate({ body: { limit: z.number().min(0).optional().default(10) } }), async (req, res) => {
    // req.body.limit -> number
});

I may end up making my own package for this tomorrow given I've used this in a couple projects now. Hope it helps!

AngaBlue avatar Jan 07 '24 15:01 AngaBlue