Setting Up API Endpoints
This article sets up procedures with full CRUD operations for the User and Task entities. You can browse the OpenAPI documentation generated from them here .
Preparations
To keep the code short, two constants list the base fields. BASE_FIELDS omits the id, entityType, createdAt, and updatedAt fields from the Zod models. BASE_KEYS holds the same keys for lodash.omit. They build the create and update Zod models, and drop these fields from entity objects when you build input objects.
import type { BaseEntity } from './types';
export const BASE_FIELDS = {
id: true,
entityType: true,
createdAt: true,
updatedAt: true,
} as const satisfies { readonly [key in keyof BaseEntity]: true };
export const BASE_KEYS = Object.keys(BASE_FIELDS) as (keyof BaseEntity)[];For example, UpdateUserSchema drops the base fields from the generated UserSchema and makes the rest optional, as the updateUser body below does:
import { UserSchema } from '@schemas/index';
import { BASE_FIELDS } from '@/constants';
const UpdateUserSchema = UserSchema.omit(BASE_FIELDS).partial(); // optional fullName, email and imageUrlImplementing Controllers and Services
The controllers declare full CRUD operations for the User and Task entities:
- Each method has the @operation decorator, which describes the operation with a
summaryand adescription. - Each method is created with the procedure function, which validates the request and emits the schema.
- Each method has the
sessionGuarddecorator, which protects the endpoint. See the Authentication article. - The “get all” procedures set the
x-tool['hidden']operation option with the@operation.tool()decorator, so they don’t become AI tools. The tools use search procedures based on OpenAI embeddings instead, which is closer to a real app.
The services hold the business logic of each procedure: database operations, and creating and searching vectors with OpenAI embeddings.
- Database requests go through
DatabaseService.prisma, a regular Prisma client with extensions . See the Database Polling article.- Delete operations return the
__isDeletedproperty, so the frontend can apply soft deletions to its state (see the State page). A Prisma client extension adds the property when aDatabaseService.prisma.xxx.deletemethod runs. - Tasks are deleted explicitly, even though the database has
ON DELETE CASCADE, so that each task deletion fires a database event.
- Delete operations return the
- After each create and update, the service calls
EmbeddingService.generateEntityEmbedding. The search procedures useEmbeddingService.vectorSearchto search vectors with OpenAI embeddings and pgvector. See the Embeddings article. - The procedures read
params,query, andbodythrough req.vovk. This lets code call them directly, not only over HTTP, through the fn interface: for SSR/PPR, server actions, and AI tool execution.
user-controller.ts
import { TaskSchema, UserSchema } from '@schemas/index';
import { del, get, operation, post, prefix, procedure, put } from 'vovk';
import { z } from 'zod';
import { BASE_FIELDS } from '@/constants';
import { sessionGuard } from '@/decorators/session-guard';
import UserService from './user-service';
@prefix('users')
export default class UserController {
@operation.tool({
hidden: true,
})
@operation({
summary: 'Get all users',
description: 'Retrieves a list of all users.',
})
@get()
@sessionGuard()
static getUsers = procedure({
output: UserSchema.array(),
}).handle(UserService.getUsers);
@operation({
summary: 'Find users by ID, full name, or email',
description:
'Retrieves users that match the provided ID, full name, or email. Used to search the users when they need to be updated or deleted.',
})
@get('search')
@sessionGuard()
static findUsers = procedure({
query: z.object({
search: z.string().meta({
description: 'Search term for users',
examples: ['john.doe', 'Jane'],
}),
}),
output: UserSchema.array(),
}).handle(({ vovk }) => UserService.findUsers(vovk.query().search));
@operation({
summary: 'Create user',
description: 'Creates a new user with the provided details.',
})
@post()
@sessionGuard()
static createUser = procedure({
body: UserSchema.omit(BASE_FIELDS),
output: UserSchema,
}).handle(async ({ vovk }) => UserService.createUser(await vovk.body()));
@operation({
summary: 'Update user',
description:
'Updates an existing user with the provided details, such as their email or name.',
})
@put('{id}')
@sessionGuard()
static updateUser = procedure({
body: UserSchema.omit(BASE_FIELDS).partial(),
params: UserSchema.pick({ id: true }),
output: UserSchema,
}).handle(async ({ vovk }) =>
UserService.updateUser(vovk.params().id, await vovk.body()),
);
@operation({
summary: 'Delete user',
description: 'Deletes a user by ID.',
})
@del('{id}')
@sessionGuard()
static deleteUser = procedure({
params: UserSchema.pick({ id: true }),
output: UserSchema.partial().extend({
__isDeleted: z.literal(true),
tasks: TaskSchema.partial()
.extend({ __isDeleted: z.literal(true) })
.array(),
}),
}).handle(async ({ vovk }) => UserService.deleteUser(vovk.params().id));
}