#CRUD API
A complete RESTful CRUD API example, demonstrating VextJS's core capabilities such as service layer, middleware, parameter verification, and error handling.
#Project structure
crud-api/
├── src/
│ ├── config/
│ │ └── default.ts
│ ├── middlewares/
│ │ └── auth.ts
│ ├── routes/
│ │ ├── index.ts
│ │ └── users.ts
│ ├── services/
│ │ └── user.ts
│ └── index.ts
├── test/
│ └── users.test.ts
├── package.json
└── tsconfig.json#1. Initialize project
npx vextjs create crud-api
cd crud-api
pnpm install#2. Configuration
// src/config/default.ts
export default {
port: 3000,
adapter: "native",
logger: {
level: "debug",
pretty: true,
},
cors: {
enabled: true,
origins: ["*"],
},
rateLimit: {
enabled: true,
max: 100,
window: 60,
},
response: {
wrap: true,
hideInternalErrors: false, // The development environment displays error details
},
openapi: {
enabled: true,
title: "CRUD API Example",
version: "1.0.0",
description: "A complete user management RESTful API",
tags: [
{ name: "Basic", description: "Basic interface" },
{ name: "User", description: "User management interface" },
],
securitySchemes: {
bearerAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
description: "Use Bearer Token authentication",
},
},
guardSecurityMap: {
auth: "bearerAuth",
},
},
// Routing-level middleware whitelist
middlewares: [{ name: "auth" }],
};
config.openapi.guardSecurityMap maps the routing middleware name 'auth' to the OpenAPI security scheme bearerAuth. When a route declares middlewares: ['auth'], the OpenAPI documentation automatically marks the interface as requiring Bearer Token authentication.
#3. Authentication middleware
// src/middlewares/auth.ts
import { defineMiddleware } from "vextjs";
/**
* Simple authentication middleware
*
* A JWT library (such as jose) should be used for token validation in production environments.
* This is simplified to static token verification for demonstration purposes.
*/
export default defineMiddleware(async (req, _res, next) => {
const authorization = req.headers.authorization;
if (!authorization) {
req.app.throw(401, "Authentication token not provided");
}
const token = authorization.replace("Bearer ", "");
if (!token || token === "undefined") {
req.app.throw(401, "Authentication token format is invalid");
}
// Simulate JWT decoding (production environment should use libraries such as jose/jsonwebtoken)
try {
// Simple example: token format is "user-{id}-{role}"
const parts = token.split("-");
if (parts.length < 3 || parts[0] !== "user") {
req.app.throw(401, "Authentication token is invalid");
}
req.user = {
id: parts[1],
role: parts[2],
};
} catch {
req.app.throw(401, "Authentication token parsing failed");
}
await next();
});Add a type declaration for req.user:
// types/vext.d.ts
declare module "vextjs" {
interface VextRequest {
user?: {
id: string;
role: string;
};
}
}#4. Service layer
// src/services/user.ts
import type { VextApp, VextLogger } from "vextjs";
/**
* User data interface
*/
interface User {
id: string;
name: string;
email: string;
age?: number;
role: string;
createdAt: string;
updatedAt: string;
}
/**
* User service
*
* Demonstrate CRUD operations using in-memory storage.
* Should be replaced with database operations (such as Drizzle ORM / Prisma) in production environments.
*/
export default class UserService {
private logger: VextLogger;
private users: Map<string, User> = new Map();
private nextId = 1;
constructor(private app: VextApp) {
this.logger = app.logger.child({ service: "UserService" });
//Initialize some test data
this.seed();
}
/**
* Fill in initial test data
*/
private seed(): void {
const seedUsers = [
{ name: "Alice", email: "alice@example.com", age: 28, role: "admin" },
{ name: "Bob", email: "bob@example.com", age: 32, role: "user" },
{ name: "Charlie", email: "charlie@example.com", role: "user" },
];for (const u of seedUsers) {
const id = String(this.nextId++);
const now = new Date().toISOString();
this.users.set(id, { id, ...u, createdAt: now, updatedAt: now });
}
this.logger.info({ count: this.users.size }, "Initial data has been loaded");
}
/**
* Query user list by page
*/
async findAll(options: {
page: number;
limit: number;
keyword?: string;
}): Promise<{
items: User[];
total: number;
page: number;
limit: number;
totalPages: number;
}> {
this.logger.debug(options, "Query user list");
let allUsers = Array.from(this.users.values());
// Keyword search (fuzzy matching by name or email)
if (options.keyword) {
const kw = options.keyword.toLowerCase();
allUsers = allUsers.filter(
(u) =>
u.name.toLowerCase().includes(kw) ||
u.email.toLowerCase().includes(kw),
);
}
const total = allUsers.length;
const totalPages = Math.ceil(total / options.limit);
const start = (options.page - 1) * options.limit;
const items = allUsers.slice(start, start + options.limit);
return {
items,
total,
page: options.page,
limit: options.limit,
totalPages,
};
}
/**
* Query users based on ID
*/
async findById(id: string): Promise<User | null> {
this.logger.debug({ userId: id }, "Query user");
return this.users.get(id) ?? null;
}
/**
* Create user
*/
async create(data: {
name: string;
email: string;
age?: number;
role?: string;
}): Promise<User> {
this.logger.info({ email: data.email }, "Create user");
// Check email uniqueness
for (const user of this.users.values()) {
if (user.email === data.email) {
this.app.throw(409, "Email has been registered", 10001);
}
}
const id = String(this.nextId++);
const now = new Date().toISOString();
const user: User = {
ID,
name: data.name,
email: data.email,
age: data.age,
role: data.role ?? "user",
createdAt: now,
updatedAt: now,
};
this.users.set(id, user);
this.logger.info({ userId: id, email: data.email }, "User created successfully");
return user;
}
/**
* Update user
*/
async update(
id: string,
data: { name?: string; email?: string; age?: number },
): Promise<User> {
this.logger.info({ userId: id }, "Update user");
const user = this.users.get(id);
if (!user) {
this.app.throw(404, "User does not exist");
}
// If updating mailbox, check for uniqueness
if (data.email && data.email !== user.email) {
for (const u of this.users.values()) {
if (u.email === data.email) {
this.app.throw(409, "The mailbox is already used by another user", 10002);
}
}
}
const updated: User = {
...user,
...data,
updatedAt: new Date().toISOString(),
};
this.users.set(id, updated);
this.logger.info({ userId: id }, "User updated successfully");
return updated;
}
/**
* Delete user
*/
async delete(id: string): Promise<void> {
this.logger.info({ userId: id }, "Delete user");
if (!this.users.has(id)) {
this.app.throw(404, "User does not exist");
}
this.users.delete(id);
this.logger.info({ userId: id }, "User deleted successfully");
}
/**
* Count the number of users
*/
async count(): Promise<number> {
return this.users.size;
}
}
VextJS's service layer is automatically loaded through a conventional directory. Place the class or object in the src/services/ directory, and the framework will be automatically instantiated and injected into app.services. The file name is the service name: user.ts → app.services.user.
The constructor of the service receives the app: VextApp parameter and can access app.logger, app.config, app.throw and other framework capabilities.
#5. Routing
#Root route (health check)
// src/routes/index.ts
import { defineRoutes } from "vextjs";export default defineRoutes((app) => {
// GET / → health check
app.get(
"/",
{
docs: {
summary: "Health Check",
tags: ["Basics"],
},
},
async (_req, res) => {
const userCount = await app.services.user.count();
res.json({
status: "ok",
uptime: Math.floor(process.uptime()),
users: userCount,
timestamp: new Date().toISOString(),
});
},
);
});#User routing (full CRUD)
// src/routes/users.ts
import { defineRoutes } from "vextjs";
export default defineRoutes((app) => {
// ━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━
// GET /users/list — Query user list by page (public)
// ━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━
app.get(
"/list",
{
validate: {
query: {
page: "number:1-", // Page number, minimum value 1
limit: "number:1-100", //Number of items per page, 1-100
keyword: "string?", // Search keyword (optional)
},
},
docs: {
summary: "User list",
description: "Query the user list in pages, supporting fuzzy search by name or email.",
tags: ["user"],
responses: {
200: {
description: "Query successful",
example: {
items: [
{
id: "1",
name: "Alice",
email: "alice@example.com",
role: "admin",
},
],
total: 3,
page: 1,
limit: 10,
totalPages: 1,
},
},
},
},
},
async (req, res) => {
const { page, limit, keyword } = req.valid("query");
const result = await app.services.user.findAll({ page, limit, keyword });
res.json(result);
},
);
// ━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━
// GET /users/:id — Query users by ID (public)
// ━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━
app.get(
"/:id",
{
validate: {
param: { id: "string:1-" },
},
docs: {
summary: "Get user details",
tags: ["user"],
responses: {
200: { description: "Query successful" },
404: { description: "User does not exist" },
},
},
},
async (req, res) => {
const { id } = req.valid("param");
const user = await app.services.user.findById(id);
if (!user) {
app.throw(404, "User does not exist");
}
res.json(user);
},
);
// ━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━
// POST /users — Create users (authentication required)
// ━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━
app.post(
"/",
{
validate: {
body: {
name: "string:1-50", // required, length 1-50
email: "email", // required, email format
age: "number:0-200?", // optional, 0-200
role: "enum:admin,user?", // optional, enumeration value
},
},
middlewares: ["auth"],
docs: {
summary: "Create user",
description: "Create a new user. Bearer Token authentication is required.",
tags: ["user"],
responses: {
201: {
description: "Created successfully",
example: {
id: "4",
name: "Diana",
email: "diana@example.com",
role: "user",
createdAt: "2026-03-05T00:00:00.000Z",
updatedAt: "2026-03-05T00:00:00.000Z",
},
},
422: { description: "Parameter verification failed" },
401: { description: "Not authenticated" },
409: { description: "Email has been registered" },
},
},
},
async (req, res) => {
const body = req.valid("body");
app.logger.info(
{ operator: req.user?.id, email: body.email },
"Operator creates user",
);const user = await app.services.user.create(body);
res.json(user, 201);
},
);
// ━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━
// PUT /users/:id — update user (authentication required)
// ━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━
app.put(
"/:id",
{
validate: {
param: { id: "string:1-" },
body: {
name: "string:1-50?", // optional
email: "email?", // optional
age: "number:0-200?", // optional
},
},
middlewares: ["auth"],
docs: {
summary: "Update user",
description:
"Update the specified user's information. Bearer Token authentication is required. Just pass in the fields that need to be updated.",
tags: ["user"],
responses: {
200: { description: "Update successful" },
422: { description: "Parameter verification failed" },
401: { description: "Not authenticated" },
404: { description: "User does not exist" },
409: { description: "The mailbox is already used by another user" },
},
},
},
async (req, res) => {
const { id } = req.valid("param");
const body = req.valid("body");
app.logger.info(
{ operator: req.user?.id, targetUser: id },
"Operator update user",
);
const user = await app.services.user.update(id, body);
res.json(user);
},
);
// ━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━
// DELETE /users/:id — delete user (authentication required)
// ━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━
app.delete(
"/:id",
{
validate: {
param: { id: "string:1-" },
},
middlewares: ["auth"],
docs: {
summary: "Delete user",
description: "Delete the specified user. Bearer Token authentication is required. This operation is irreversible.",
tags: ["user"],
responses: {
204: { description: "Delete successfully (no response body)" },
401: { description: "Not authenticated" },
404: { description: "User does not exist" },
},
},
},
async (req, res) => {
const { id } = req.valid("param");
app.logger.info(
{ operator: req.user?.id, targetUser: id },
"Operator delete user",
);
await app.services.user.delete(id);
res.status(204).json(null);
},
);
});#6. Entry file
// src/index.ts
import { bootstrap } from "vextjs";
bootstrap().catch((err) => {
console.error("Startup failed:", err);
process.exit(1);
});#7. Test
// test/users.test.ts
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createTestApp } from "vextjs/testing";
import type { TestApp } from "vextjs";
describe("User CRUD", () => {
let testApp: TestApp;
const AUTH_TOKEN = "user-1-admin"; // Impersonate administrator token
beforeEach(async () => {
testApp = await createTestApp();
});
afterEach(async () => {
await testApp?.close();
});
// ── Query ─────────────────────────────────────
describe("GET /users/list", () => {
it("Should return a paginated user list", async () => {
const res = await testApp.request
.get("/users/list")
.query({ page: 1, limit: 10 });
expect(res.status).toBe(200);
expect(res.body.code).toBe(0);
expect(Array.isArray(res.body.data.items)).toBe(true);
expect(res.body.data.total).toBeGreaterThan(0);
expect(res.body.data.page).toBe(1);
});
it("Support keyword search", async () => {
const res = await testApp.request
.get("/users/list")
.query({ page: 1, limit: 10, keyword: "alice" });
expect(res.status).toBe(200);
expect(res.body.data.items.length).toBe(1);
expect(res.body.data.items[0].name).toBe("Alice");
});
it("Paging parameter verification failed and 422 should be returned", async () => {
const res = await testApp.request
.get("/users/list")
.query({ page: 0, limit: 10 }); // The minimum value of page is 1
expect(res.status).toBe(422);
});
});describe("GET /users/:id", () => {
it("User details should be returned if present", async () => {
const res = await testApp.request.get("/users/1");
expect(res.status).toBe(200);
expect(res.body.data).toMatchObject({
id: "1",
name: "Alice",
email: "alice@example.com",
});
});
it("Should return 404 when it does not exist", async () => {
const res = await testApp.request.get("/users/999");
expect(res.status).toBe(404);
expect(res.body.message).toBe("The user does not exist");
});
});
//── Create ─────────────────────────────────────
describe("POST /users", () => {
it("User should be created successfully after authentication", async () => {
const res = await testApp.request
.post("/users")
.set("Authorization", `Bearer ${AUTH_TOKEN}`)
.send({
name: "Diana",
email: "diana@example.com",
age: 25,
});
expect(res.status).toBe(201);
expect(res.body.code).toBe(0);
expect(res.body.data).toMatchObject({
name: "Diana",
email: "diana@example.com",
age: 25,
role: "user",
});
expect(res.body.data.id).toBeDefined();
expect(res.body.data.createdAt).toBeDefined();
});
it("Unauthenticated should return 401", async () => {
const res = await testApp.request
.post("/users")
.send({ name: "Test", email: "test@example.com" });
expect(res.status).toBe(401);
});
it("Duplicate mailbox should return 409", async () => {
const res = await testApp.request
.post("/users")
.set("Authorization", `Bearer ${AUTH_TOKEN}`)
.send({
name: "Alice Copy",
email: "alice@example.com", // already exists
});
expect(res.status).toBe(409);
expect(res.body.code).toBe(10001);
});
it("If name is empty, 422 should be returned", async () => {
const res = await testApp.request
.post("/users")
.set("Authorization", `Bearer ${AUTH_TOKEN}`)
.send({
name: "",
email: "new@example.com",
});
expect(res.status).toBe(422);
expect(res.body.errors).toBeDefined();
});
it("email format is invalid and returns 422", async () => {
const res = await testApp.request
.post("/users")
.set("Authorization", `Bearer ${AUTH_TOKEN}`)
.send({
name: "Valid Name",
email: "not-an-email",
});
expect(res.status).toBe(422);
});
});
//── Update ────────────────────────────────────
describe("PUT /users/:id", () => {
it("User should be updated successfully after authentication", async () => {
const res = await testApp.request
.put("/users/1")
.set("Authorization", `Bearer ${AUTH_TOKEN}`)
.send({ name: "Alice Updated" });
expect(res.status).toBe(200);
expect(res.body.data.name).toBe("Alice Updated");
expect(res.body.data.email).toBe("alice@example.com"); // Unmodified fields remain unchanged
});
it("Updating non-existing users should return 404", async () => {
const res = await testApp.request
.put("/users/999")
.set("Authorization", `Bearer ${AUTH_TOKEN}`)
.send({ name: "Ghost" });
expect(res.status).toBe(404);
});
it("Mailbox conflict should return 409", async () => {
const res = await testApp.request
.put("/users/1")
.set("Authorization", `Bearer ${AUTH_TOKEN}`)
.send({ email: "bob@example.com" }); // Bob’s email
expect(res.status).toBe(409);
expect(res.body.code).toBe(10002);
});
});
//──Delete ─────────────────────────────────────describe("DELETE /users/:id", () => {
it("The user should be deleted successfully after authentication", async () => {
const res = await testApp.request
.delete("/users/2")
.set("Authorization", `Bearer ${AUTH_TOKEN}`);
expect(res.status).toBe(204);
// Confirm deletion
const getRes = await testApp.request.get("/users/2");
expect(getRes.status).toBe(404);
});
it("Deleting non-existent users should return 404", async () => {
const res = await testApp.request
.delete("/users/999")
.set("Authorization", `Bearer ${AUTH_TOKEN}`);
expect(res.status).toBe(404);
});
it("Unauthenticated should return 401", async () => {
const res = await testApp.request.delete("/users/1");
expect(res.status).toBe(401);
});
});
// ── Health Checkup ────────────────────────────────
describe("GET /", () => {
it("Service status should be returned", async () => {
const res = await testApp.request.get("/");
expect(res.status).toBe(200);
expect(res.body.data).toMatchObject({
status: "ok",
users: expect.any(Number),
});
});
});
});#8. Run
#Development mode
pnpmdevAfter startup you can:
- Visit
http://localhost:3000/to view the health check - Visit
http://localhost:3000/docsto view the automatically generated Scalar API documentation - Use
curlto test each interface
#Run the test
pnpm test#9. Interface testing
#HealthCheck
curl http://localhost:3000/
# → {"code":0,"data":{"status":"ok","users":3,...},"requestId":"..."}
# Query user list
curl "http://localhost:3000/users/list?page=1&limit=10"
# → {"code":0,"data":{"items":[...],"total":3,"page":1,"limit":10,"totalPages":1},"requestId":"..."}
#Search for users
curl "http://localhost:3000/users/list?page=1&limit=10&keyword=alice"
# → {"code":0,"data":{"items":[{"id":"1","name":"Alice",...}],...},"requestId":"..."}
# Query a single user
curl http://localhost:3000/users/1
# → {"code":0,"data":{"id":"1","name":"Alice","email":"alice@example.com",...},"requestId":"..."}
#Create user (authentication required)
curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-H "Authorization: Bearer user-1-admin" \
-d '{"name":"Diana","email":"diana@example.com","age":25}'
# → 201 {"code":0,"data":{"id":"4","name":"Diana",...},"requestId":"..."}
#Update user (authentication required)
curl -X PUT http://localhost:3000/users/1 \
-H "Content-Type: application/json" \
-H "Authorization: Bearer user-1-admin" \
-d '{"name":"Alice Updated"}'
# → {"code":0,"data":{"id":"1","name":"Alice Updated",...},"requestId":"..."}
# Delete user (authentication required)
curl -X DELETE http://localhost:3000/users/2 \
-H "Authorization: Bearer user-1-admin"
# → 204 No Content
# Unauthenticated access to protected interface
curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"name":"Test","email":"test@example.com"}'
# → 401 {"code":-1,"message":"Authentication token not provided","requestId":"..."}
# Parameter verification failed
curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-H "Authorization: Bearer user-1-admin" \
-d '{"name":"","email":"invalid"}'
# → 422 {"code":422,"message":"Validation failed","errors":[...],"requestId":"..."}#10. Summary of key concepts
#Request-response process
client request
→ requestId middleware (generate/transparently transmit request ID)
→ CORS middleware (handling cross-domain)
→ body-parser middleware (parse the request body)
→ access-log middleware (logging start time)
→ rate-limit middleware (rate limit check)
→ response-wrapper middleware (open export packaging)
→ auth middleware (validate token, inject req.user) ← protected routes only
→ validate middleware (parameter verification) ← When validate is configured
→ handler (business logic)
→ export package ({ code: 0, data, requestId })
→ response returns#Error handling process
app.throw(404, 'User does not exist') in handler
→ throw HttpError
→ error-handler middleware capture
→ Convert to standard error response
→ {"code":-1,"message":"User does not exist","requestId":"..."}
→ HTTP 404#Design patterns
| Mode | Description |
|---|---|
| Three-stage routing | app.method(path, options, handler) — Declarative configuration |
| Service layer separation | Business logic is encapsulated in src/services/, routing is only arranged |
| Middleware whitelist | Route-level middleware must be declared in config.middlewares |
| Declarative validation | validate uses schema-dsl DSL syntax, automatic type conversion |
| Unified error handling | app.throw() throws an error, and the framework automatically converts to a standard format |
| Export packaging | All successful responses are automatically packaged as { code: 0, data, requestId } |
| OpenAPI Auto-Generation | Automatically generate API documentation from validate and docs configurations |
#Next step
- 📖Zod Validation Integration — Use Zod to replace the built-in schema-dsl validation
- 📖Drizzle ORM integration — Connect to Drizzle ORM to implement real database operations
- 📖Prisma ORM integration — Connect to Prisma ORM to implement real database operations
- 📖 Testing — Learn more about advanced usage of VextJS testing tools
- 📖 OpenAPI Documentation — Learn more about OpenAPI’s auto-generated configuration options