Skip to content

Instantly share code, notes, and snippets.

@seralexeev
Last active February 7, 2023 22:39
Show Gist options
  • Save seralexeev/3d7068f7d426680a31c1cac102735ca6 to your computer and use it in GitHub Desktop.
Save seralexeev/3d7068f7d426680a31c1cac102735ca6 to your computer and use it in GitHub Desktop.

RPC / REST

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 description
    • anonymous - whether the endpoint is anonymous or not
      • if anonymous is true then the user argument will be ApiUser | null
      • if anonymous is false then the user argument will be ApiUser
    • input - input schema
    • output - output schema
    • resolve - endpoint handler
  • The rest method receives an object with the following fields:

    • description - endpoint description
    • anonymous - whether the endpoint is anonymous or not
      • if anonymous is true then the user argument will be ApiUser | null
      • if anonymous is false then the user argument will be ApiUser
    • input - input schema
    • output - output schema
    • resolve - endpoint handler
    • method - HTTP method
      • we can specify multiple methods using an array
      • we can specify method using POST /ingest/product or GET /ingest/product syntax. In this case the method 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 and JsonValue 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);
        },
    });
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment