Aquila169 / zod-express-middleware

Express middleware to validate requests using zod schema's.
MIT License
86 stars 13 forks source link

Default values from schema not applied to Request object #13

Open jstorm31 opened 1 year ago

jstorm31 commented 1 year ago

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.

AngaBlue commented 1 year ago

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.

jstorm31 commented 1 year ago

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

AngaBlue commented 1 year ago

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.

RobMayer commented 1 year ago

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

        }
    );
jstorm31 commented 1 year ago

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

AngaBlue commented 1 year ago

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

jstorm31 commented 1 year ago

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

AngaBlue commented 9 months ago

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!