Skip to Content

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.

src/constants.ts
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 imageUrl

Implementing 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 summary and a description.
  • Each method is created with the procedure function, which validates the request and emits the schema.
  • Each method has the sessionGuard decorator, 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 __isDeleted property, so the frontend can apply soft deletions to its state (see the State page). A Prisma client extension adds the property when a DatabaseService.prisma.xxx.delete method runs.
    • Tasks are deleted explicitly, even though the database has ON DELETE CASCADE, so that each task deletion fires a database event.
  • After each create and update, the service calls EmbeddingService.generateEntityEmbedding. The search procedures use EmbeddingService.vectorSearch to search vectors with OpenAI embeddings and pgvector. See the Embeddings article.
  • The procedures read params, query, and body through 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.
src/modules/user/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)); }

The code above is fetched from GitHub repository. 

Last updated on