Skip to Content
ControllerCallable Handlers

Callable Handlers

Every controller method created with the withValidationLibrary function (used under the hood by all validation libraries) can be used outside an HTTP request context as a regular function. It exposes an fn method that calls the handler directly, simulating the signature of an RPC 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 validation meta: { hello: 'world' }, // available as xMetaHeader metadata }); console.log('Response:', response);

But the fn method allows you to call the controller method directly, in the current evaluation context, without performing an HTTP request:

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 client-side validation meta: { hello: 'world' }, // available as root metadata }); console.log('Response:', response);

This will invoke UserController.updateUser like a normal function, performing validation with Zod (or another 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 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 the createLLMTools utility to call controller methods in 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 the VovkRequest object by exposing only the custom vovk property with async vovk.body(), vovk.query(), vovk.params(), and vovk.meta(). In other words, properties such as req.url or req.headers are not defined in the callable context. A suitable req signature looks like Pick<VovkRequest<TBody, TQuery, TParams>, 'vovk'>.

To ensure that the implemented HTTP handler can also be called with fn, you can use object destructuring when defining the handler to use only the 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 HTTP handler. If you create a decorator to wrap an HTTP handler for use with fn, avoid using NextRequest‑specific properties like 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 detect the callable context by checking req.url, which is undefined when called via fn:

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 req is not a Request, you can still use Next.js features like headers or cookies from next/headers in the callable context:

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

Good to Know

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

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

When a 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