fastify / fastify

Fast and low overhead web framework, for Node.js
https://www.fastify.dev
Other
32.36k stars 2.3k forks source link

No way to type an ephemeral (short lived) entity for a request #5681

Closed lifeiscontent closed 1 month ago

lifeiscontent commented 1 month ago

Prerequisites

Fastify version

4.x.x

Plugin version

No response

Node.js version

20.x

Operating system

Linux

Operating system version (i.e. 20.04, 11.3, 10)

MacOS 14.6.1

Description

Hi, I've been looking through the docs and it seems there's something called routeOptions which might be what I want, but might not.

I've effectively got some code that registers some data via a preHandler, and I think I'm doing things incorrectly, but basically, I'm wondering if there's some kind of typed context in fastify to disclose the attributes that will exist by the time certain preHandlers run.

Here's some controller code:

import { Type } from "@sinclair/typebox";
// File: src/controllers/users_controller.ts
import type { FastifyInstance } from "fastify";
import { type Notice, NoticeSchema } from "~/models/error";
import { type Permission, PermissionSchema } from "~/models/permission";
import {
    type CreateUser,
    CreateUserSchema,
    type ReadUserParams,
    ReadUserParamsSchema,
    type UpdateUser,
    UpdateUserSchema,
    type User,
    UserSchema,
} from "~/models/user";
import UserPolicy from "~/policies/user_policy";
import UserRepo from "~/repos/user_repo";
import { RepoNotFoundError } from "~/repos/utils";
import type {
    PreHandlerAsyncHookHandler,
    PreHandlerHookHandler,
} from "./utils";

export default async function UsersController(fastify: FastifyInstance) {
    // NOTE: we add the preHandlers here instead of the module scope in case
    // we want to enable fastify autoload

    const requireViewerPreHandler: PreHandlerHookHandler<
        Record<string, unknown>,
        { viewer: User }
    > = (request, _reply, done) => {
        request.routeConfig.viewer = {
            email: "user-1@example.com",
            id: "user-1",
            created_at: new Date().toISOString(),
            updated_at: new Date().toISOString(),
            username: "user-1",
            is_active: true,
        };

        done();
    };

    const requireUserPreHandler: PreHandlerAsyncHookHandler<
        { Params: ReadUserParams },
        { user: User }
    > = async (request, reply) => {
        try {
            request.routeConfig.user = await UserRepo.read(request.params);
        } catch (error) {
            if (error instanceof RepoNotFoundError) {
                return reply.status(404).send({ message: error.message });
            }
            throw error;
        }
    };

    fastify.get<
        {
            Params: ReadUserParams;
            Reply: User | Permission | Notice;
        },
        {
            user: User;
            viewer: User;
        }
    >(
        "/:id",
        {
            preHandler: [requireViewerPreHandler, requireUserPreHandler],
            preValidation: async (request, reply, done) => {
                const permission = UserPolicy.canRead(
                    request.routeConfig.user,
                    request.routeConfig,
                );
                if (!permission.allowed) {
                    return reply.status(403).send(permission);
                }

                done();
            },
            schema: {
                params: Type.Ref(ReadUserParamsSchema),
                response: {
                    200: Type.Ref(UserSchema),
                    403: Type.Ref(PermissionSchema),
                    404: Type.Ref(NoticeSchema),
                },
                tags: ["users"],
                summary: "Get a user by ID",
            },
        },
        async (request, reply) => {
            return reply.send(request.routeConfig.user);
        },
    );

    fastify.post<
        {
            Body: CreateUser;
            Reply: User | Permission;
        },
        {
            user: User;
            viewer: User;
        }
    >(
        "/",
        {
            preHandler: requireViewerPreHandler,
            preValidation: async (request, reply, done) => {
                const permission = UserPolicy.canCreate(request.routeConfig);
                if (!permission.allowed) {
                    return reply.status(403).send(permission);
                }

                done();
            },
            schema: {
                body: Type.Ref(CreateUserSchema),
                response: {
                    201: Type.Ref(UserSchema),
                    403: Type.Ref(PermissionSchema),
                },
                tags: ["users"],
                summary: "Create a new user",
            },
        },
        async (request, reply) => {
            const newUser = await UserRepo.create(request.body);
            return reply.status(201).send(newUser);
        },
    );

    fastify.patch<
        {
            Params: ReadUserParams;
            Body: UpdateUser;
            Reply: User | Permission;
        },
        {
            user: User;
            viewer: User;
        }
    >(
        "/:id",
        {
            preHandler: [requireViewerPreHandler, requireUserPreHandler],
            preValidation: async (request, reply, done) => {
                const permission = UserPolicy.canUpdate(
                    request.routeConfig.user,
                    request.routeConfig,
                );
                if (!permission.allowed) {
                    return reply.status(403).send(permission);
                }

                done();
            },
            schema: {
                params: Type.Ref(ReadUserParamsSchema),
                body: Type.Ref(UpdateUserSchema),
                response: {
                    200: Type.Ref(UserSchema),
                    403: Type.Ref(PermissionSchema),
                },
                tags: ["users"],
                summary: "Update a user",
            },
        },
        async (request, reply) => {
            const updatedUser = await UserRepo.update(request.params, request.body);
            return reply.send(updatedUser);
        },
    );

    fastify.delete<
        {
            Params: ReadUserParams;
            Reply: User | Permission;
        },
        {
            user: User;
            viewer: User;
        }
    >(
        "/:id",
        {
            preHandler: [requireViewerPreHandler, requireUserPreHandler],
            preValidation: async (request, reply, done) => {
                const permission = UserPolicy.canDelete(
                    request.routeConfig.user,
                    request.routeConfig,
                );
                if (!permission.allowed) {
                    return reply.status(403).send(permission);
                }

                done();
            },
            schema: {
                params: Type.Ref(ReadUserParamsSchema),
                response: {
                    204: Type.Null(),
                    403: Type.Ref(PermissionSchema),
                },
                tags: ["users"],
                summary: "Delete a user",
            },
        },
        async (request, reply) => {
            await UserRepo.delete(request.params);
            return reply.status(204).send();
        },
    );
}

in this example, I've got some prehandlers that then are used with preValidations to check if the constraints for the route are valid, I'm using routeOptions to store this information, the reason why I'm not putting it directly on the request, is because I don't want to modify the request interface globally like the docs say to do because these entities only exist for a very short period of time.

is there a way to type this kind of thing safely?

Link to code that reproduces the bug

No response

Expected Behavior

I'd expect to be able to pass some data through handlers in a request in a type safe way.

mcollina commented 1 month ago

That code is not accessible. My guess is this would be fixed in https://github.com/fastify/fastify/pull/5672.

lifeiscontent commented 1 month ago

@mcollina sorry, I've added the code to the PR

lifeiscontent commented 1 month ago

though you are correct, I believe #5672 would be a way of solving it.

climba03003 commented 1 month ago

Let track on https://github.com/fastify/fastify/issues/5061