Setting Up API Endpoints
This article describes how to set up API endpoints/procedures for User and Task entities with full CRUD operations. The OpenAPI documentation generated from these procedures can be explored here .
Preparations
For the sake of shorter code, we create a reusable constant BASE_FIELDS that is used to omit id, entityType, createdAt, and updatedAt fields from the Zod models, and a BASE_KEYS array that contains the keys of these fields for use with lodash.omit. This helps us build proper create/update Zod models and omit these fields from entity objects when we need to construct 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, here’s how the UpdateUserSchema can be created by omitting the base fields from the generated UserSchema:
import { UserSchema } from '@schemas/index';
import { BASE_FIELDS } from '@/constants';
const UpdateUserSchema = UserSchema.omit(BASE_FIELDS); // fullName and email fields onlyImplementing Controllers and Services
The controllers declare full CRUD operations for User and Task entities, implemented as follows:
- Each method is decorated with the @operation decorator that describes the operation, including
summaryanddescription. - Each method is created with the procedure function that implements request validation and schema emission.
- Each method uses the
sessionGuarddecorator to protect the endpoints, as described in the Authentication article. - The “get all” endpoints use the
x-tool['hidden']operation option implemented with the@operation.tool()decorator to exclude them from being used as AI tools. Instead, the tools use search endpoints that rely on OpenAI Embeddings to simulate more realistic scenarios.
The services implement the actual business logic for each procedure, including database operations and vector generation/search via OpenAI embeddings.
- Database requests are invoked using
DatabaseService.prisma, where theprismaproperty is a regular Prisma client instance with extensions . This is explained in more detail in the Database Polling article.- Deletion operations return the
__isDeletedproperty to help the frontend reconcile state properly for soft deletions (see the State page). The property is added by a Prisma client extension whenDatabaseService.prisma.xxx.deletemethods are invoked. - To trigger database events on task deletions, tasks are deleted explicitly, even though
ON DELETE CASCADEis configured at the database level.
- Deletion operations return the
- Creations and updates are followed by
EmbeddingService.generateEntityEmbeddingcalls, and the search endpoints useEmbeddingService.vectorSearchto perform vector search using OpenAI embeddings and pgvector. For details, see the Embeddings article. - The controller procedures use req.vovk to access request data such as
params,query, andbody, because we want to call these methods directly from code (not only through HTTP requests) via the fn interface for SSR/PPR and AI tool execution.
UserController.ts
import { procedure, prefix, get, put, post, del, operation } from "vovk";
import { z } from "zod";
import { TaskSchema, UserSchema } from "@schemas/index";
import { sessionGuard } from "@/decorators/sessionGuard";
import { BASE_FIELDS } from "@/constants";
import UserService from "./UserService";
@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),
});
}