@ditsmod/trpc
Модуль @ditsmod/trpc
забезпечує інтеграцію з @trpc/server. Готовий приклад застосунку з @ditsmod/trpc
можна проглянути у репозиторії Ditsmod. Там ви можете знайти приклади застосування ґардів та інтерсепторів.
Швидкий старт
Ви також можете скористатись моно-репозиторієм, в якому є мінімальний код для швидкого старту:
git clone --depth 1 https://github.com/ditsmod/trpc-monorepo-starter.git
Як формуються типи для клієнта на рівні модуля
Ditsmod намагається бути прозорим для @trpc/client
надаючи можливість TypeScript виводити типи зі статичного коду, без необхідності додаткової компіляції для клієнта. Кожен модуль, що надає конфігурацію для tRPC-роутера, повинен це робити у методі getRouterConfig()
:
import { featureModule } from '@ditsmod/core';
import { initTrpcModule, ModuleWithTrpcRoutes } from '@ditsmod/trpc';
import { RouterOf } from '@ditsmod/trpc/client';
import { CommentModule } from './comments/comment.module.js';
import { PostController } from './post.controller.js';
// For TRPCClient
export type PostRouter = RouterOf<typeof PostModule>;
@initTrpcModule({
imports: [CommentModule],
controllers: [PostController],
})
@featureModule()
export class PostModule implements ModuleWithTrpcRoutes {
getRouterConfig() {
return {
post: {
createPost: PostController.prototype.createPost, // Pointed to a controller
comments: CommentModule.prototype.getRouterConfig, // Pointed to a module
},
};
}
}
Тут ModuleWithTrpcRoutes
- це інтерфейс, який гарантує наявність методу getRouterConfig()
у даному модулі.
В даному прикладі, показано конфіг, на основі якого буде створено:
- роут
post.createPost
, за який відповідатиме метод контролера -PostController.prototype.createPost
; - група роутів
post.comments
, за який відповідатиме імпортований модуль -CommentModule.prototype.getRouterConfig
. Можна здогадатись, щоCommentModule
має свій методgetRouterConfig()
, в якому вже уточнюється які саме контролери створюють певні роути.
Зверніть увагу, що тут створюється тип PostRouter
для tRPC-клієнта. Це рекомендується робити для кожного невкладеного(!) модуля, щоб пом'якшити проблеми з TypeScript-перформенсом, коли він виводить типи зі складних моделей. Але пам'ятайте, що такі типи не будуть коректно працювати для вкладених модулів. У даному прикладі CommentModule
є вкладеним, тому для нього не доцільно робити export type CommentsRouter = RouterOf<typeof CommentsModule>
.
Також ви можете централізовано виводити єдиний тип для змердженого tRPC-роутера на рівні застосунку, але це рекомендується робити лише у випадку, якщо у вас немає планів створювати складні моделі, аналізуючи які TypeScript буде "помирати". Щоб централізовано вивести єдиний роутер на увесь застосунок, треба скористатись AppRouterHelper
:
import { rootModule } from '@ditsmod/core';
import type { SetAppRouterOptions, TrpcCreateOptions, TrpcRootModule } from '@ditsmod/trpc';
import type { AppRouterHelper } from '@ditsmod/trpc/client';
import { PostModule } from '#post/post.module.js';
import { AuthModule } from '#auth/auth.module.js';
import { MessageModule } from '#message/message.module.js';
const modulesWithTrpcRoutes = [AuthModule, PostModule, MessageModule] as const;
export type AppRouter = AppRouterHelper<typeof modulesWithTrpcRoutes>;
@rootModule({
imports: [...modulesWithTrpcRoutes],
})
export class AppModule implements TrpcRootModule {
setTrpcCreateOptions(): TrpcCreateOptions {
return {
// Passing options for initTRPC.create()
};
}
setAppRouterOptions(): SetAppRouterOptions {
return {
basePath: '/trpc/',
};
}
}
Зверніть увагу, що у AppRouterHelper
передається не просто масив імпортованих модулів, а цей масив ще й позначено за допомогою as const
- це важлива умова, без якої AppRouterHelper
працюватиме некоректно.
Також зверніть увагу на інтерфейс TrpcRootModule
, який вимагає обов'язкового впровадження методу setAppRouterOptions()
, також опціонально можна імплементувати setTrpcCreateOptions()
. Коли ваш метод setAppRouterOptions()
повертає конфіг для роутера, ви не зможете передати опцію createContext
, оскільки Ditsmod автоматично створює контекст у вигляді об'єкту { req, res }
щоб гарантувати доступність цих змінних в контексті. Звичайно ж, в процедурах ви можете додавати будь-які інші властивості контекста.
Як формуються типи для клієнта на рівні метода контролера
Кожен метод контролера, що створює роут, повинен мати декоратор trpcRoute
та повинен поверти tRPC-процедуру:
import { trpcController, TrpcRouteService, trpcRoute } from '@ditsmod/trpc';
import { z } from 'zod';
@trpcController()
export class PostController {
@trpcRoute()
createPost(routeService: TrpcRouteService) {
return routeService.procedure.input(z.object({ title: z.string() })).mutation(({ input }) => {
return { ...input, id: 1, body: 'post text' };
});
}
}
Тобто, якщо вам достатньо користуватись перевагами DI на рівні роуту (а не на рівні HTTP-запиту), то ваш код буде мало відрізнятись від нативного tRPC-коду. Практично єдина відмінність лише у тому, що вам початкову процедуру потрібно брати із TrpcRouteService
, як показано у даному прикладі. До речі, TrpcRouteService
можна вказувати тип контексту та інпуту - TrpcRouteService<SomeContext, SomeInput>
. Майте на увазі, що якщо ви збираєтесь у коді писати routeService.procedure.input(...)
, то вам не потрібно передавати другий дженерік, бо типи інпутів будуть конфліктувати. Другий дженерік є сенс використовувати у парі з routeService.procedureAfterInput
, який варто використовувати у випадку, якщо валідацію ви робите автоматично в інтерсепторах, а н е безпосередньо у коді роуту.
Окрім TrpcRouteService
, у параметрах методу контролера ви можете запитувати будь-який інший сервіс на рівні роуту, причому порядок параметрів не має значення:
@trpcController()
export class PostController {
@trpcRoute()
createPost(service1: Service1, service2: Service2, routeService: TrpcRouteService) {
// ...
}
}
У випадку, якщо вам потрібно буде використовувати ґарди чи інтерсептори, вам достатньо їх додати відповідно у перший та другий масив у декораторі trpcRoute
:
import { trpcController, TrpcRouteService, trpcRoute } from '@ditsmod/trpc';
import { z } from 'zod';
import { BearerGuard } from '../auth/bearer.guard.js';
import { MyInterceptor } from './my.interceptor.js';
@trpcController()
export class PostController {
@trpcRoute([BearerGuard], [MyInterceptor])
createPost(routeService: TrpcRouteService) {
return routeService.procedure.input(z.object({ title: z.string() })).mutation(({ input }) => {
return { ...input, id: 1, body: 'post text' };
});
}
}
Як користуватись типами роутера на клієнті
Як було сказано вище, щоб пом'якшити проблеми з TypeScript-перформенсом, рекомендується виводити тип для кожного невкладеного(!) модуля. Під "невкладеним модулем" мається на увазі той модуль, який безпосередньо імпортується у кореневий модуль. Отже, під час створення клієнта, рекомендується використовувати узагальнений тип для роутера - createTRPCClient<AnyTRPCRouter>()
, а потім уточнювати тип для кожного окремого модуля:
import type { AnyTRPCRouter } from '@trpc/server';
import { createTRPCClient, TRPCClient } from '@trpc/client';
import type { PostRouter } from 'server'; // You can give this name in your monorepo
const trpc = createTRPCClient<AnyTRPCRouter>({
// ...
});
const postClient = trpc as TRPCClient<PostRouter>;
const post = await postClient.post.createPost.mutate({ title: 'hello client' });
Використання DI для провайдерів на рівні HTTP-запиту
Коли ви пишете наступний код, DI вам забезпечить роботу провайдерів на рівні роуту:
@trpcController()
export class PostController {
constructor(service1: Service1) {}
@trpcRoute()
listPosts(service2: Service2, routeService: TrpcRouteService) {
return routeService.procedure.query(() => this.service1.messages);
}
}
В даному прикладі, DI вирішить з алежність для Service1
, Service2
та TrpcRouteService
на рівні роуту. Якщо ж ви хочете щоб DI працював також на рівні HTTP-запиту, вам треба зробити три кроки:
- Створити ClassFactoryProvider, який буде працювати на рівні запиту.
- Передати новостворений провайдер до DI на рівні запиту.
- Використати новостворений провайдер з одним із методів
TrpcRouteService
, ім'я якого має префіксdi
(наприклад,diQuery
,diMutation
і т.д.).
Давайте пройдемо ці кроки разом.
Крок перший
import { injectable, factoryMethod } from '@ditsmod/core';
import { opts, TrpcOpts } from '@ditsmod/trpc';
import { DbService } from '#db/db.service.js';
import { InputPost } from '#models/post.js';
@injectable()
export class PostService {
@factoryMethod()
method1(@opts opts: TrpcOpts<any, InputPost>, db: DbService) {
// ...
return posts;
}
}
Зверніть увагу, що на рівні методу даний провайдер має декоратор, причому не важливо який саме, головне, щоб він створювався за допомогою відповідних Ditsmod-хелперів.
Крок другий
Найпростіше передати ClassFactoryProvider
до DI - за допомогою хелпера Providers
:
import { trpcController } from '@ditsmod/trpc';
import { Providers } from '@ditsmod/core';
import { PostService } from '#post/post.service.js';
// ...
@trpcController({
providersPerReq: new Providers().useFactories(PostService),
})
export class PostController {
// ...
}
До речі, в даному прикладі провайдери передаються у метадані контролера, але їх також можна передавати і у метадані модуля на рівні запиту.
Метод providers.useFactories()
автоматично сканує наявність методів з декораторами у переданому класі, і для кожного методу створює відповідний провайдер. Наприклад, якщо ви передаєте providers.useFactories(PostService)
, і у PostService
є три методи з декораторами на рівні методу, то providers.useFactories(PostService)
передасть до DI приблизно такі провайдери:
[
{ useFactory: [PostService, PostService.prototype.method1] },
{ useFactory: [PostService, PostService.prototype.method2] },
{ useFactory: [PostService, PostService.prototype.method3] },
]
Крок третій
Після того, як провайдери передані до DI, їх можна використовувати у наступній формі:
import { TrpcRouteService, trpcRoute } from '@ditsmod/trpc';
import { PostService } from '#post/post.service.js';
//...
export class PostController {
@trpcRoute()
listPosts(routeService: TrpcRouteService) {
return routeService.diQuery(PostService.prototype.method1);
}
}
Тобто, метод routeService.diQuery()
приймає метод контролера, а DI у реєстрі шукає провайдера з таким токеном, і повертає його значення. В такому разі, під час кожного запиту буде створюватись інстанс PostService
та викликатись його метод method1
.