Skip to Content
ControllerCallable handlers

Callable handlers

Every controller method created with withValidationLibrary function, that works behind the scenes of all validation libraries, can be used outside of HTTP request context as a regular function. It provides fn method that can be used to call the method directly, simulating signature of an RPC module method.

Let’s say you have the following controller class:

src/modules/user/UserController.ts
import { z } from 'zod'; import { prefix, post, operation } from 'vovk'; import { withZod } from 'vovk-zod'; import UserService from './UserService'; @prefix('users') export default class UserController { @operation({ summary: 'Update user', description: 'Update user by ID with Zod validation', }) @post('{id}') static updateUser = withZod({ body: z .object({ name: z.string().describe('User full name'), age: z.number().min(0).max(120).describe('User age'), email: z.email().describe('User email'), }) .describe('User object'), params: z.object({ id: z.uuid().describe('User ID'), }), query: z.object({ notify: z.enum(['email', 'push', 'none']).describe('Notification type'), }), output: z .object({ success: z.boolean().describe('Success status'), id: z.uuid().describe('User ID'), }) .describe('Response object'), async handle(req) { const body = await req.vovk.body(); const query = req.vovk.query(); const params = req.vovk.params(); const meta = req.vovk.meta<{ hello: string; xMetaHeader: { hello: string } }>(); // ... }, }); }

When it’s compiled to an RPC module, the HTTP request can be performed like this:

import { UserRPC } from 'vovk-client'; const response = await UserRPC.updateUser({ params: { id: '69' }, body: { name: 'John Doe', age: 30, email: 'john.doe@example.com', }, query: { notify: 'email', }, disableClientValidation: true, // disables client-side JSON validation meta: { hello: 'world' }, // available as xMetaHeader metadata }); console.log('Response:', response);

But the fn method allows you to call the controller method directly:

import UserController from '@/modules/user/UserController'; const response = await UserController.updateUser.fn({ params: { id: '69' }, body: { name: 'John Doe', age: 30, email: 'john.doe@example.com', }, query: { notify: 'email', }, disableClientValidation: true, // disables Zod validation meta: { hello: 'world' }, // available as root metadata }); console.log('Response:', response);

This will invoke UserController.updateUser method just like a normal function, performing validation with Zod (or other library) before executing the handler.

There are several core use cases for callable handlers:

For SSR, SSG, PPR etc: You can use the method as a server-side function, allowing you to call it from the server-side code without performing an HTTP request.

src/app/page.tsx
import UserController from '@/modules/user/UserController'; export default async function Page() { const response = await UserController.updateUser.fn({ params: { id: '69' }, body: { name: 'John Doe', age: 30, email: 'john.doe@example.com', }, query: { notify: 'email', }, }); console.log('Response:', response); return ( <div> <h1>User Updated</h1> <p>Response: {JSON.stringify(response)}</p> </div> ); }

For LLM function calling: Each controller can be used as a “module” for LLM function calling, implemented with createLLMTools utility in order to call controller methods at the current context, without performing HTTP requests.

import { createLLMTools } from 'vovk'; const { tools } = createLLMTools({ modules: { UserController }, }); console.log(tools); // [{ name, description, parameters, execute }, ...]

Rules of callable handlers

fn functions do not imitate the Request object, but partially implement VovkRequest object, providing only the custom vovk property with methods to access input data, such as async vovk.body(), vovk.query(), vovk.params(), and vovk.meta(). In other words, req properties such as req.url or req.headers are not defined in the callable handler context. A proper signature of the req object looks like Pick<VovkRequest<Tbody, TQuery, TParams>, 'vovk'>. In order to make sure that the implemented HTTP handler can be also called with fn, you can use object destructing when defining the handler to use only vovk property:

export default class UserController { @post('{id}') static updateUser = withZod({ body: z.object({ /* ... */ }), params: z.object({ /* ... */ }), query: z.object({ /* ... */ }), output: z.object({ /* ... */ }), async handle({ vovk }) { const body = await vovk.body(); const query = vovk.query(); const params = vovk.params(); const meta = vovk.meta<{ hello: string }>(); // ... }, }); }

Another thing to keep in mind is that the callable handler is decorated with the same decorators as the original HTTP handler, so when you create a decorator that is going to wrap the HTTP handler that is going to be used with fn, you need to make sure that you don’t use req properties related to NextRequest, such as req.url, req.headers or req.nextUrl.

import { createDecorator } from 'vovk'; const myDecorator = createDecorator(({ vovk }, next) => { const meta = vovk.meta<{ foo: string }>(); // ... return next(); });

You can check if the handler is called as a callable handler by checking req.url property, which is undefined in the callable handler context:

import { createDecorator } from 'vovk'; const myDecorator = createDecorator((req, next) => { if (typeof req.url === 'undefined') { console.log('Callable handler context'); } else { console.log('HTTP request context'); } // ... return next(); });

Even though the req argument isn’t a Request object, you can still use built-in Next.js features such as headers or cookies from next/headers to access headers and cookies in the callable handler context:

import { createDecorator } from 'vovk'; import { headers } from 'next/headers'; const myDecorator = createDecorator(async ({ vovk }, next) => { const headers = await headers(); // ... return next(); });

Good to know

An HTTP decorator such as @get, @post etc isn’t required for the callable handler to work, the only requirement is to use withValidationLibrary function (via withZod for example) to create the handler.

src/modules/user/UserHandlers.ts
import { z } from 'zod'; import { withZod } from 'vovk'; export default class UserHandlers { static updateUser = withZod({ body: z.object({ /* ... */ }), // ... }); } UserHandlers.updateUser.fn({ /* ... */ });

When static class is implemented this way, it behaves like a “validated service”, which can be attached to the controller later, or used as a standalone collection of validated functions.

src/modules/user/UserController.ts
import { prefix, post } from 'vovk'; import UserHandlers from './UserHandlers'; @prefix('users') export default class UserController { @post('{id}') static updateUser = UserHandlers.updateUser; }
Last updated on