Skip to Content

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.

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, 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 only

Implementing 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 summary and description.
  • Each method is created with the procedure function that implements request validation and schema emission.
  • Each method uses the sessionGuard decorator 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 the prisma property is a regular Prisma client instance with extensions . This is explained in more detail in the Database Polling article.
    • Deletion operations return the __isDeleted property to help the frontend reconcile state properly for soft deletions (see the State page). The property is added by a Prisma client extension when DatabaseService.prisma.xxx.delete methods are invoked.
    • To trigger database events on task deletions, tasks are deleted explicitly, even though ON DELETE CASCADE is configured at the database level.
  • Creations and updates are followed by EmbeddingService.generateEntityEmbedding calls, and the search endpoints use EmbeddingService.vectorSearch to 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, and body, 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.
src/modules/user/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), }); }

The code above is fetched from GitHub repository. 

Last updated on