Controller
Create a controller with CLI
npx vovk new controller admin/user # Create a new controller for the "admin" segment
Definition
Controller is a static class (a class that has static methods only and itβs never instantiated) that handles incoming HTTP requests.
Methods of this class, that are decorated with an HTTP decorator (like @get()
or @post()
), become request handlers and accept 2 arguments: VovkRequest (that is inherited from NextRequest
) and parameters that are defined by the decorator path.
The NextRequest
type doesnβt provide any information about the request body or query parameters, so the body is casted as any
and query parameters are casted as string | null
.
import type { NextRequest } from 'next';
import { prefix, put } from 'vovk';
@prefix('users')
export default class UserController {
// Example request: PUT /api/users/69?role=moderator
@put('{id}')
static async updateUser(req: NextRequest, { id }: { id: string }) {
const data = await req.json(); // any
const userRole = req.nextUrl.searchParams.get('role'); // string | null
// ...
return updatedUser;
}
}
To add the required body and query types you need replace NextRequest
by VovkRequest
and define the types as generic arguments.
import { prefix, put, type VovkRequest } from 'vovk';
import type { User } from '../../types';
@prefix('users')
export default class UserController {
// Example request: PUT /api/users/69?role=moderator
@put('{id}')
static async updateUser(
req: VovkRequest<Partial<User>, { notify: 'email' | 'push' | 'none' }>,
{ id }: { id: string }
) {
const data = await req.json(); // Partial<User>
const notify = req.nextUrl.searchParams.get('notify'); // 'email' | 'push' | 'none'
// ...
return updatedUser;
}
}
Hint: to make sure that the class is never instantiated, you can make it abstract.
export default abstract class UserController {
/* ... */
}
As you can see weβve changed nothing more than the type of req
but now data
receives type of Partial<User>
and userRole
is casted as 'user' | 'moderator' | 'admin'
and does not extend null
anymore.
Validation
To validate the request body, query parameters, and headers you can use validation libraries. At this case you donβt need to use VovkRequest
manually, as the validation library will define the request type by itself.
import { z } from 'zod';
import { withZod } from 'vovk-zod';
import { prefix, post, openapi } from 'vovk';
@prefix('users')
export default class UserController {
@openapi({
summary: 'Update user (Zod)',
description: 'Update user by ID with Zod validation',
})
@post('{id}')
static updateUser = withZod({
body: z
.object({
name: z.string().meta({ description: 'User full name' }),
age: z.number().min(0).max(120).meta({ description: 'User age' }),
email: z.email().meta({ description: 'User email' }),
})
.meta({ description: 'User object' }),
params: z.object({
id: z.uuid().meta({ description: 'User ID' }),
}),
query: z.object({
notify: z.enum(['email', 'push', 'none']).meta({ description: 'Notification type' }),
}),
output: z
.object({
success: z.boolean().meta({ description: 'Success status' }),
})
.meta({ description: 'Response object' }),
async handle(req, { id }) {
const { name, age } = await req.json();
const notify = req.nextUrl.searchParams.get('notify');
// do something with the data
console.log(`Updating user ${id}:`, { name, age, notify });
return {
success: true,
};
},
});
}
Initialization
Once the controller is defined it needs to be initialized at the wildcard route (also called segment) by adding it to the controllers
object. The key of this object defines the name of the resulting library variable exported from "vovk-client"
or segmented client module.
import { initSegment } from 'vovk';
import UserController from '../../../modules/user/UserController';
const controllers = {
UserRPC: UserController,
};
export type Controllers = typeof controllers;
export const { GET, POST, PUT, DELETE } = initSegment({ controllers });
This will βcompileβ the UserController
into UserRPC
that performs fetch
requests to the /api/users
subpath.
import { UserRPC } from 'vovk-client';
// performs PUT /api/users/69?notify=push
const updatedUser = await UserRPC.updateUser({
query: { notify: 'push' },
params: { id: '69' },
body: { ...userData },
});
Auto-generated endpoints
All HTTP decorators provide .auto
method that generates endpoint name automatically from the method name that makes the handler definition more RPC-ish.
import { prefix, put } from 'vovk';
@prefix('users')
export default class UserController {
// creates PUT /api/users/do-something
@put.auto()
static async doSomething(/* ... */) {
// ...
}
}
Return types
Custom object
The HTTP-decorated static methods of controllers can return several kinds of objects. For example, a regular object literal.
// ...
export default class HelloController {
@get()
static helloWorld() {
return { hello: 'world' };
}
}
Another example - if the controller method returns Prisma ORM invocation the type is going to be recognised accordingly.
// ...
export default class UserController {
@get()
static async updateUser(/* ... */) {
// ...
return prisma.user.update({
where: { id },
data,
});
}
}
At this case the returned value of client method UserRPC.updateUser
gets User
type generated at @prisma/client.
Response object
HTTP handlers can also return regular Response
object, including NextResponse
.
// ...
export default class HelloController {
@get()
static helloWorld() {
return new Response(JSON.stringify({ hello: 'world' }), {
headers: { 'Content-Type': 'application/json' },
}) as unknown as { hello: string };
}
}
When NextResponse
is returned from the controller method, the client library is going to recognise the return type as expected.
import { NextResponse } from 'next/server';
// ...
export default class HelloController {
@get()
static helloWorld() {
return NextResponse.json({ hello: 'world' }, { status: 200 });
}
}