elysiajs / elysia

Ergonomic Framework for Humans
https://elysiajs.com
MIT License
10.68k stars 227 forks source link

Route splitting without loosing type inference #138

Open artecoop opened 1 year ago

artecoop commented 1 year ago

Hello all! one of the most difficult situation, at least for me to switch to elysia from either hono or another node framework is the missing ability to split rounding code among files. I've seen some proposal, even file system routing. But for a simple situation (e.g.: 5/6 routes, each having some endpoints) is there is any workaround or guidance?

Thanks!

Luisetepe commented 1 year ago

Hi! Having probably the same problem here. It's very easy and clean to create group with prefixes for every "resource". For example:

import { apiSetup } from '@api/api.utils'
import { Elysia } from 'elysia'
import {
    createProductHandler,
    createProductHandlerSchema
} from './create-product.endpoint'
import {
    getAllProductsHandler,
    getAllProductsHandlerSchema
} from './get-all-products.endpoint'

export const apiProducts = new Elysia({ prefix: '/products' })
    .use(apiSetup)
    .post('/', createProductHandler, createProductHandlerSchema)
    .get('/', getAllProductsHandler, getAllProductsHandlerSchema)

That would be my index.ts of the folder that is going to contain 1 file per route, with this file containing both the handler and the handler schema. The big problem right now is typing the handler function (tried several things with Context, TypedRoute and another couple of things, but no luck)

import { TypedRoute, t } from 'elysia'

export const createProductHandlerSchema = {
    body: t.Object({
        name: t.String({ error: 'name is required' }),
        pvp: t.Numeric({ error: 'pvp is required' }),
        categoryId: t.Numeric({ error: 'categoryId is required' })
    }),
    response: {
        200: t.Object({
            id: t.Numeric(),
            name: t.String(),
            pvp: t.Numeric(),
            categoryId: t.Numeric()
        }),
        400: t.String()
    }
}

export async function createProductHandler({ body, store }: /* What type?? */) {
    // do something with 'body'
    return {
        id: 1,
        name: 'CocaCola',
        pvp: 10,
        categoryId: 4
    }
}
Luisetepe commented 1 year ago

Hi everyone again! Not ideal in my opinion (I'd love to have a single generic class for all my project) but I ended up surrendering to a per-handler typing, adding only what you need to the type. In the example above it would be only the body, and a type that I declared for my shared state store:

import { ApiStore } from '@api/api.types'
import { Static } from '@sinclair/typebox'
import { Context, t } from 'elysia'

export const createProductHandlerSchema = {
    body: t.Object({
        name: t.String({ error: 'name is required' }),
        pvp: t.Numeric({ error: 'pvp is required' }),
        categoryId: t.Numeric({ error: 'categoryId is required' })
    }),
    response: {
        200: t.Object({
            id: t.Numeric(),
            name: t.String(),
            pvp: t.Numeric(),
            categoryId: t.Numeric()
        }),
        400: t.String()
    }
}

export async function createProductHandler({
    body,
    store
}: Context<
    { body: Static<typeof createProductHandlerSchema.body> },
    ApiStore
>) {
    // do something with 'body'
    return {
        id: 1,
        name: 'CocaCola',
        pvp: 10,
        categoryId: 4
    }
}
Luisetepe commented 1 year ago

Hi again, and sorry for the spam. I'm leaving the last comment for the record, but I've managed to come up with a simple type that "FOR NOW" is working for me. Full example:

api.utils.ts

import { DbContext, dbPool } from '@infrastructure/persistence/db.utils'
import { Static, TSchema } from '@sinclair/typebox'
import Elysia, { Context } from 'elysia'

export type ApiStore = {
    dbPool: DbContext
}

export type ApiHandlerParams<Schema> = Context<
    {
        [Property in keyof Schema]: Schema[Property] extends TSchema
            ? Static<Schema[Property]>
            : Schema[Property]
    },
    ApiStore
>

export const apiSetup = new Elysia({ name: 'apiSetup' }).state('dbPool', dbPool)

create-product.endpoint.ts

import { ApiHandlerParams } from '@api/api.utils'
import { t } from 'elysia'

export const createProductHandlerSchema = {
    body: t.Object({
        name: t.String({ error: 'name is required' }),
        pvp: t.Numeric({ error: 'pvp is required' }),
        categoryId: t.Numeric({ error: 'categoryId is required' })
    })
}

export async function createProductHandler({
    body,
    store
}: ApiHandlerParams<typeof createProductHandlerSchema>) {
    // here 'body' and 'store' have proper intellisense and types
    return {
        id: 1,
        name: 'CocaCola',
        pvp: 10,
        categoryId: 4
    }
}

index.ts

import { apiSetup } from '@api/api.utils'
import { Elysia } from 'elysia'
import {
    createProductHandler,
    createProductHandlerSchema
} from './create-product.endpoint'

export const apiProducts = new Elysia({ prefix: '/products' })
    .use(apiSetup)
    .post('/', createProductHandler, createProductHandlerSchema)
Luisetepe commented 1 year ago

Just an update. This does not seem to work in the validation side, I keep getting BAD REQUEST errors hitting the endpoint, like I'm sending nothing in the body and like my response in empty.

{
    "fields": [
        {
            "type": 34,
            "schema": {
                "elysiaMeta": "Numeric",
                "type": "number"
            },
            "path": "/id",
            "message": "Expected number"
        },
        {
            "type": 48,
            "schema": {
                "type": "string"
            },
            "path": "/name",
            "message": "Expected string"
        },
        {
            "type": 34,
            "schema": {
                "elysiaMeta": "Numeric",
                "type": "number"
            },
            "path": "/pvp",
            "message": "Expected number"
        },
        {
            "type": 34,
            "schema": {
                "elysiaMeta": "Numeric",
                "type": "number"
            },
            "path": "/categoryId",
            "message": "Expected number"
        },
        {
            "type": 44,
            "schema": {
                "elysiaMeta": "Numeric",
                "type": "number"
            },
            "path": "/id",
            "message": "Expected required property"
        },
        {
            "type": 44,
            "schema": {
                "type": "string"
            },
            "path": "/name",
            "message": "Expected required property"
        },
        {
            "type": 44,
            "schema": {
                "elysiaMeta": "Numeric",
                "type": "number"
            },
            "path": "/pvp",
            "message": "Expected required property"
        },
        {
            "type": 44,
            "schema": {
                "elysiaMeta": "Numeric",
                "type": "number"
            },
            "path": "/categoryId",
            "message": "Expected required property"
        }
    ]
}

If I put everything in endpoint definition, everything works as expected

export const apiProducts = new Elysia({ prefix: '/products' })
    .use(apiSetup)
    .post(
        '/',
        ({ body, store }) => ({
            id: 1,
            name: 'CocaCola',
            pvp: 10,
            categoryId: 4
        }),
        {
            body: t.Object({
                name: t.String({ error: 'name is required' }),
                pvp: t.Numeric({ error: 'pvp is required' }),
                categoryId: t.Numeric({ error: 'categoryId is required' })
            }),
            response: t.Object({
                id: t.Numeric(),
                name: t.String(),
                pvp: t.Numeric(),
                categoryId: t.Numeric()
            })
        }
    )

Is there any example of declaring handlers and schemas in another file being possible, or at least for now we have to declare them inline?

sahibalejandro commented 1 year ago

Having all endpoints in one file is a real deal breaker for many of us, even if you manage to split your routes into groups with multiple Elysia instances, there could be very large groups.

This is hurts development experience for sure, I hope some one smarter than me comes with a good fix, I've tried several ways to workaround this with no luck.

gtramontina commented 1 year ago

Reading the docs, the dependency injection section seems to attempt to address it, more specifically here.

I'm not entirely sure if I followed what's been suggested there, though. 🤔

Luk4h commented 1 year ago

Hello, guys! Got myself in a similar problem, I was looking to type my body from the schema. After some looking at the types files and trying to find a solution... I think I got something! 🎉

Here is my sample code:

  private initializeRoutes() {
    const users = new Elysia({ prefix: '/auth' })
      .post('/register', this.register, RegisterBody)
  }

  private register = async ({ body, set }) => {
    const { emailAddress, passwordString, firstName, lastName, cpfNumber } = body;
    if ( await this.AuthService.checkIfUserExists(cpfNumber) )
      return new HttpException(400, 'Já existe um usuário com este CPF.');

    try {
      const newUser = await this.AuthService.create({ emailAddress, passwordString, firstName, lastName, cpfNumber });
      set.status = 201;
      return newUser;
    } catch (err: unknown) {
      if (err instanceof HttpException)
        return err;
      return new HttpException(500, `Ocorreu um erro interno: ${JSON.stringify(err)}}`);
    }
  }

Sadly as this is, all the parameters on the register function are any:

image

To type that you can simply put the parameter as Context:

image

But we enter in another problem as the body now is any:

image

To solve that I found the UnwrapRoute Type, which can receive a typeof a schema and automatically types the body:

image

I haven't started working with this yet, as I just found this solution and got no type errors or anys. Our example should now be looking like this:

private initializeRoutes() {
    const users = new Elysia({ prefix: '/auth' })
      .post('/register', this.register, RegisterBody)
  }

  private register = async ({ body, set }: Context<UnwrapRoute<typeof RegisterBody>>) => {
    const { emailAddress, passwordString, firstName, lastName, cpfNumber } = body;
    if ( await this.AuthService.checkIfUserExists(cpfNumber) )
      return new HttpException(400, 'Já existe um usuário com este CPF.');

    try {
      const newUser = await this.AuthService.create({ emailAddress, passwordString, firstName, lastName, cpfNumber });
      set.status = 201;
      return newUser;
    } catch (err: unknown) {
      if (err instanceof HttpException)
        return err;
      return new HttpException(500, `Ocorreu um erro interno: ${JSON.stringify(err)}}`);
    }
  }

TLDR: Context<UnwrapRoute<typeof Schema>>

artecoop commented 1 year ago

@SaltyAom could you please check @Luk4h answer and confirm this is the way?

Luk4h commented 1 year ago

Hello, guys! Got myself in a similar problem, I was looking to type my body from the schema. After some looking at the types files and trying to find a solution... I think I got something! 🎉

Here is my sample code:

  private initializeRoutes() {
    const users = new Elysia({ prefix: '/auth' })
      .post('/register', this.register, RegisterBody)
  }

  private register = async ({ body, set }) => {
    const { emailAddress, passwordString, firstName, lastName, cpfNumber } = body;
    if ( await this.AuthService.checkIfUserExists(cpfNumber) )
      return new HttpException(400, 'Já existe um usuário com este CPF.');

    try {
      const newUser = await this.AuthService.create({ emailAddress, passwordString, firstName, lastName, cpfNumber });
      set.status = 201;
      return newUser;
    } catch (err: unknown) {
      if (err instanceof HttpException)
        return err;
      return new HttpException(500, `Ocorreu um erro interno: ${JSON.stringify(err)}}`);
    }
  }

Sadly as this is, all the parameters on the register function are any: image

To type that you can simply put the parameter as Context: image

But we enter in another problem as the body now is any: image

To solve that I found the UnwrapRoute Type, which can receive a typeof a schema and automatically types the body: image

I haven't started working with this yet, as I just found this solution and got no type errors or anys. Our example should now be looking like this:

private initializeRoutes() {
    const users = new Elysia({ prefix: '/auth' })
      .post('/register', this.register, RegisterBody)
  }

  private register = async ({ body, set }: Context<UnwrapRoute<typeof RegisterBody>>) => {
    const { emailAddress, passwordString, firstName, lastName, cpfNumber } = body;
    if ( await this.AuthService.checkIfUserExists(cpfNumber) )
      return new HttpException(400, 'Já existe um usuário com este CPF.');

    try {
      const newUser = await this.AuthService.create({ emailAddress, passwordString, firstName, lastName, cpfNumber });
      set.status = 201;
      return newUser;
    } catch (err: unknown) {
      if (err instanceof HttpException)
        return err;
      return new HttpException(500, `Ocorreu um erro interno: ${JSON.stringify(err)}}`);
    }
  }

TLDR: Context<UnwrapRoute<typeof Schema>>

This sadly doesn't have plugin types. If I wanted to set a cookie, or sign a jwt, or any other plugin, sadly it does not have them. All the types are embedded on the Elysia object, making it very hard to work with. I really would like to see the project used in the documentation's code.

thecodeassassin commented 11 months ago

I assume this is also the reason why I'm getting this:

Argument of type '(context: Context) => Promise<string | null>' is not assignable to parameter of type 'Handler<MergeSchema<UnwrapRoute<InputSchema<never>, {}>, {}>, { request: {}; store: {}; }, "/user/metadata/:key">'.
  Types of parameters 'context' and 'context' are incompatible.

when doing this:


app.get('/user/metadata/:key', metadata.get)

I really don't want to create a huge file or use any for context. Is there any fix for this?

dnguyenfs commented 11 months ago

@SaltyAom this hurt development so far, this should be put at high priority pls. We only need this think fix then we can use it on production.

vastamaki commented 11 months ago

This is a bit problematic, right now it's pretty much impossible to create a single file containing all route definitions and having the actual functions in separate files. Hopefully, this is something that will be fixed soon.

asyncci commented 11 months ago

That what I was looking for ! Bump ! Here is the same problem image

saurabhsri108 commented 11 months ago

Hi everyone, is there any solution for this situation?

user.route.ts

import { Elysia } from "elysia";
import { registerUser } from "../controllers/user.controller";
import { registerSchemaDTO } from "../schemaDTO/user.dto";

export const userRoutes = new Elysia({ prefix: "/users" }).post(
    "/",
    registerUser,
    registerSchemaDTO
);

user.controller.ts

import type { Context } from "elysia";

export const registerUser = ({ body }: Context) => {
    // get user details from the user
    const { username } = body;  // this does not works
    // validate user details
    // sanitize user details
    // check if user already exists
    // check if images, check for avatar
    // if images available, upload it to cloudinary
    // create user object - create entry in db
    // get the new user response without password and refresh token field
    // check if response is returned successfully - null check
    // return the response to the user
    return {};
};

user.dto.ts

import { t } from "elysia";

export const registerSchemaDTO = {
    body: t.Object({
        username: t.String({ error: "Username is required" }),
        email: t.String({ error: "Email is required" }),
        fullname: t.String({ error: "Fullname is required" }),
        avatar: t.String({ error: "User profile picture is required" }),
        coverImage: t.String(),
        password: t.String({ error: "Password is required" }),
    }),
    response: {
        200: t.Object({}),
        400: t.String(),
    },
};

The problem is that the user.controller.ts file's body de-structuring is not typesafe. If I write it like below, it works:

user.route.ts

import { Elysia } from "elysia";
import { registerSchemaDTO } from "../schemaDTO/user.dto";

export const userRoutes = new Elysia({ prefix: "/users" }).post(
    "/",
    ({body}) => { 
         const {username} = body; // this now works
         return {}
        },
    registerSchemaDTO
);
saurabhsri108 commented 11 months ago

Hi again, and sorry for the spam. I'm leaving the last comment for the record, but I've managed to come up with a simple type that "FOR NOW" is working for me. Full example:

api.utils.ts

import { DbContext, dbPool } from '@infrastructure/persistence/db.utils'
import { Static, TSchema } from '@sinclair/typebox'
import Elysia, { Context } from 'elysia'

export type ApiStore = {
    dbPool: DbContext
}

export type ApiHandlerParams<Schema> = Context<
    {
        [Property in keyof Schema]: Schema[Property] extends TSchema
            ? Static<Schema[Property]>
            : Schema[Property]
    },
    ApiStore
>

export const apiSetup = new Elysia({ name: 'apiSetup' }).state('dbPool', dbPool)

create-product.endpoint.ts

import { ApiHandlerParams } from '@api/api.utils'
import { t } from 'elysia'

export const createProductHandlerSchema = {
    body: t.Object({
        name: t.String({ error: 'name is required' }),
        pvp: t.Numeric({ error: 'pvp is required' }),
        categoryId: t.Numeric({ error: 'categoryId is required' })
    })
}

export async function createProductHandler({
    body,
    store
}: ApiHandlerParams<typeof createProductHandlerSchema>) {
    // here 'body' and 'store' have proper intellisense and types
    return {
        id: 1,
        name: 'CocaCola',
        pvp: 10,
        categoryId: 4
    }
}

index.ts

import { apiSetup } from '@api/api.utils'
import { Elysia } from 'elysia'
import {
    createProductHandler,
    createProductHandlerSchema
} from './create-product.endpoint'

export const apiProducts = new Elysia({ prefix: '/products' })
    .use(apiSetup)
    .post('/', createProductHandler, createProductHandlerSchema)

This solution worked for me FOR NOW, it seems.

utils/apiSetup.ts

import { Static, TSchema } from "@sinclair/typebox";
import Elysia, { Context } from "elysia";

export type ApiHandlerParams<Schema> = Context<{
    [Property in keyof Schema]: Schema[Property] extends TSchema
        ? Static<Schema[Property]>
        : Schema[Property];
}>;

export const apiSetup = new Elysia({ name: "apiSetup" });

Then, I simply used it as the first middleware of the main Elysia app init:

import { cookie } from "@elysiajs/cookie";
import { cors } from "@elysiajs/cors";
import serverTiming from "@elysiajs/server-timing";
import staticPlugin from "@elysiajs/static";
import { swagger } from "@elysiajs/swagger";
import { Elysia } from "elysia";
import { compression } from "elysia-compression";
import { rateLimit } from "elysia-rate-limit";
import { apiSetup } from "./utils/apiSetup";

const app = new Elysia({ prefix: "/api/v1" })
    .use(apiSetup)
    .use(
        cors({
            origin: Bun.env.CORS_ORIGIN!,
            credentials: true,
        })
    )
    .use(
        rateLimit({
            responseMessage: "Max limit of 10 calls per minute exceeded",
            countFailedRequest: true,
        })
    )
    .use(cookie())
    .use(
        staticPlugin({
            assets: "public",
        })
    )
    .use(compression())
    .use(serverTiming())
    .use(swagger());

export default app;

Just one issue is that it doesn't work in production deployment. It works perfectly in development mode. In prod mode, it's just going to throw

{"type":"body"}
lnfel commented 6 months ago

Please use the plugin pattern instead of TS gymnastics your way to type everything, all you would have in the end is a messy unmaintainable types.

https://elysiajs.com/essential/plugin.html

SaltyAom commented 6 months ago

For a future reference, please see https://elysiajs.com/patterns/mvc.html

alpharder commented 4 months ago

For a future reference, please see https://elysiajs.com/patterns/mvc.html

With all respect, this isn't ergonomic at all

Pedromigacz commented 4 months ago

For a future reference, please see https://elysiajs.com/patterns/mvc.html

Is it possible to just expose the types instead telling us to not do that? Trying to move to the recommended way of doing it raises so many other issues, for me at least. I figured out how to make the typescript work for my use case. My only issue would be not being able to rely on Elysia types in the future.

sekoyo commented 4 months ago

You should use new Elysia()... and add the plugins you want type inference on again. Plugins should have a name (and optionally a seed), Elysia then knows not to add them twice.

// myPlugin.ts
const myPlugin = new Elysia({ name: 'myplugin'})
    .decorate('greeting', 'Hello you')

// subRoutes.ts
const subRoutes = new Elysia()
    .use(myPlugin)
    .get('/greetme-again', ({ greeting }) => greeting)

// index.ts
const app = new Elysia()
    .use(myPlugin)
    .use(subRoutes)
    .get('/greetme', ({ greeting }) => greeting)
    .listen(3000)

This also means that your "mini applications" are encapsulated with their dependencies.

L-Mario564 commented 2 months ago

IDK if this is "the definitive solution" but thought I'd share how I personally handle splitting routes in a project of mine.

// base.ts
import { Elysia } from 'elysia';

// You can add whatever logic and state every route shares here
export const base = new Elysia().decorate('env', { ... }); // I can now access env vars via Elysia context

// routers/user.ts
export const userRouter = new Elysia({ prefix: '/user' })
  .use(base)
  .get('/', async (context) => {
    context.env; // This works
    // ... 
  });

// index.ts
import { Elysia } from 'elysia';
import { base } from './base';
import { userRouter } from './routers/user';

const app = new Elysia()
  .use(base)
  .use(userRouter)
  .listen(3001);

This is almost the same as @sekoyo's approach, although in this case, base can be used to add any shared logic rather having a specific purpose. This way, you only use one .use() instead of multiple.

SaltyAom commented 2 months ago

Hi, just in case we have just added something that might help to the documentation:

https://elysiajs.com/essential/handler.html#typescript

iansummerlin commented 1 month ago

I have achieved something similar with the plugin pattern

// server.ts
new Elysia()
    .use(getContact);

// getContact.ts
export const getContact = new Elysia().get(
  "/contacts/:id",
  (context) => {
    const contactId = context.params.id; // ok
    const tuna = context.params.tuna // typescript error
    const body = context.body.john // typescript error
  },
  {
    params: t.Object({
      id: t.String(),
    })
  }
);

but I agree I would prefer this:

// server.ts
new Elysia()
      .get("/contacts/:id", getContact, getContactSchema);