ControllerInvoker is a base part of the request handling pipeline.
- it is responsible for invoking the endpoint handler with the correct arguments.
- it is responsible for getting the user from headers / cookies / etc.
ControllerInvoker
can be a controller itself. If we do integration with a service that doesn't have much logic in it- we use class field method names with namespace as a endpoint name
export abstract class ControllerInvoker<TContext = unknown, TUser = unknown> {
public abstract invoke(
resolve: (args: InvokeArgs<TUser> & { ctx: TContext & { user: TUser | null } }) => unknown,
args: InvokeArgs<TUser>,
options: {
config: RpcEndpointConfig<unknown, unknown> | RestEndpointConfig<unknown, unknown>;
endpoint: string;
},
): Promise<unknown>;
public abstract getUser(ctx: HttpContext): Promise<TUser | null>;
protected rpc = <TInput, TOutput, TAnonymous extends true | undefined>(
config: EndpointConfig<TContext, TUser, TInput, TOutput, TAnonymous>,
): RpcEndpointConfig<TInput, TOutput> => new RpcEndpointConfig<TInput, TOutput>(this, config);
protected rest = <TInput, TOutput, TAnonymous extends true | undefined>(
config: EndpointConfig<TContext, TUser, TInput, TOutput, TAnonymous> & { method?: HttpMethod | HttpMethod[] },
): RestEndpointConfig<TInput, TOutput> => new RestEndpointConfig<TInput, TOutput>(this, config);
}
For example:
@Singleton()
class ApiInvoker extends ControllerInvoker<ApiContext, ApiUser> {
private config;
public constructor(private pg: Pg, private logger: ApiLogger, { config }: Config) {
super();
this.config = config;
}
public override invoke: ControllerInvoker<ApiContext, ApiUser>['invoke'] = async (resolve, args, options) => {
// check user permissions
// do some logging
// create a database transaction and pass it to the endpoint handler
// so that all queries within a single request are executed in a single transaction
return this.pg.transaction(async (t) => {
return resolve({ ctx: { t, user }, input, ...args });
});
};
public override getUser: ControllerInvoker<ApiContext, ApiUser>['getUser'] = async (args) => {
// check user from cookies
};
// invokers can be controllers themselves if they are small and don't have much logic
// and if we don't need to share auth / context initialization / invoking logic across multiple controllers
public ['POST /ingest/product'] = this.rest({
input: z.object({ id: z.number() }),
resolve: async ({ ctx }) => {
// some logic
},
});
}
Then we can create endpoint factory using the ApiInvoker:
export const { rpc, rest } = createEndpointFactory<RpcContext, ApiUser>(ApiInvoker);
Using rpc
and rest
factories we can create endpoints:
import { rest, rpc } from '../rpc';
@Singleton()
export class ProductController {
public constructor(private productService: ProductService) {}
public ['products/getRecommendations'] = rpc({
description: 'Get recommended products',
anonymous: true,
input: z.object({}),
output: listOutputSchema(productSchema),
resolve: async ({ ctx }) => {
// some logic
},
});
public ['POST /ingest/product'] = rest({
input: z.object({ id: z.number() }),
resolve: async ({ ctx }) => {
// some logic
},
});
public ['products/export'] = rpc({
resolve: ({ req, ctx }) => {
return this.upload(ctx, req.file);
},
});
public async upload(ctx: RpcContext, files: Express.Multer.File | undefined) {
// some logic
}
}
-
The
rpc
method receives an object with the following fields:description
- endpoint descriptionanonymous
- whether the endpoint is anonymous or not- if
anonymous
istrue
then theuser
argument will beApiUser | null
- if
anonymous
isfalse
then theuser
argument will beApiUser
- if
input
- input schemaoutput
- output schemaresolve
- endpoint handler
-
The
rest
method receives an object with the following fields:description
- endpoint descriptionanonymous
- whether the endpoint is anonymous or not- if
anonymous
istrue
then theuser
argument will beApiUser | null
- if
anonymous
isfalse
then theuser
argument will beApiUser
- if
input
- input schemaoutput
- output schemaresolve
- endpoint handlermethod
- HTTP method- we can specify multiple methods using an array
- we can specify method using
POST /ingest/product
orGET /ingest/product
syntax. In this case themethod
field will be ignored
All the controllers are collected in a single registry:
// this type is used on the client side
export type SomeApi = RpcApi<typeof controllers>;
export const controllers = {
ProductController,
UserController,
OrderController,
};
- all endpoints are flattened into a single object
- we don't use superjson but handle type transformation for types like
Date
,Big
,FileResult
andJsonValue
manually
export type RpcApi<T> = Merge<
{ [K in keyof T]: T[K] extends Class<any> ? RpcControllerApi<InstanceType<T[K]>> : never }[keyof T]
>;
type RpcControllerApi<T> = OmitNever<{
[K in keyof T]: T[K] extends RpcEndpointConfig<infer TInput, infer TOutput>
? [TInput] extends [never]
? { output: Serializable<TOutput> }
: { output: Serializable<TOutput>; input: TInput extends z.ZodType<any, any, infer Q> ? Q : never }
: never;
}>;
// just an example of custom serialization that applies to all endpoints at the last step of inference
// prettier-ignore
type Serializable<T>
= T extends Date ? string
: T extends FileResult ? { data: Blob; filename: string }
: T extends Big ? string
: T extends JsonValue ? T
: T extends Array<infer A> ? Array<Serializable<A>>
: T extends string | number | boolean | null | undefined ? T
: T extends Record<string, any> ? { [K in keyof T]: Serializable<T[K]> }
: unknown;
- all class are decorated with
@Singleton()
decorator. We use tsyringe as a DI container. - the client side code uses reqct-query and handles file upload / download and similar to the tRPC implementation
export type RpcEndpoint = InferRpcEndpoint<SomeApi>;
export type RpcOutput<T extends RpcEndpoint> = InferRpcOutput<SomeApi, T>;
export type RpcInput<T extends RpcEndpoint> = InferRpcInput<SomeApi, T>;
export const { useRpc, useInvalidate, useReset } = createRpcHook<SomeApi>({ path: '/rpc' });
// in a react component
const { mutateAsync, isLoading } = useRpc('products/upsert').useMutation({
// modified react query
invalidates: ['products/find', 'products/getById'],
onSuccess: () => console.log('success'),
});
// inter service communication:
const client = new ServiceClient<SomeApi>({ baseURL: 'http://localhost:3000' });
await client.invoke('product/upsert', {
input: { ... }
})
The last step is to create a server and register services that cant be initialized without explicit arguments in the DI container
export const createApi = async () => {
const config = await Config.load();
return createServer(config, {
controllers,
registerServices: async (args) => {
const { container, logger } = args;
container.register(Config, { useValue: new Config(config) });
container.register(Pg, { useValue: new Pg(logger, createConfig('api', config.db, config.db.replicas)) });
container.register(GoogleMapsClient, { useValue: new GoogleMapsClient(config.googleMaps.apiKey) });
},
preInitialize: async ({ container }) => {
// Run migrations
await container.resolve(Migrations).run();
},
postInitialize: ({ container }) => {
container.resolve(LaunchDarklyService);
container.resolve(MongoClientProvider);
},
});
};