Aller au contenu principal

REST Endpoint with Express

This example shows how to create a professional REST endpoint with Express, Zod validation and tests.

Command used

/dev:dev-api "Create a CRUD endpoint for articles with pagination"

Generated structure

src/
├── routes/
│ └── articles.ts # Express routes
├── controllers/
│ └── articleController.ts
├── services/
│ └── articleService.ts
├── schemas/
│ └── articleSchema.ts # Zod validation
├── middleware/
│ └── validate.ts # Validation middleware
└── tests/
└── articles.test.ts

Endpoint code

schemas/articleSchema.ts

import { z } from 'zod';

export const articleSchema = z.object({
id: z.string().uuid(),
title: z.string().min(3).max(200),
slug: z.string().regex(/^[a-z0-9-]+$/),
content: z.string().min(10),
excerpt: z.string().max(300).optional(),
authorId: z.string().uuid(),
status: z.enum(['draft', 'published', 'archived']),
tags: z.array(z.string()).default([]),
publishedAt: z.coerce.date().nullable(),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
});

export const createArticleSchema = articleSchema.omit({
id: true,
slug: true,
createdAt: true,
updatedAt: true,
publishedAt: true,
});

export const updateArticleSchema = createArticleSchema.partial();

export const listArticlesQuerySchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
status: z.enum(['draft', 'published', 'archived']).optional(),
authorId: z.string().uuid().optional(),
tag: z.string().optional(),
search: z.string().optional(),
sortBy: z.enum(['createdAt', 'publishedAt', 'title']).default('createdAt'),
sortOrder: z.enum(['asc', 'desc']).default('desc'),
});

export type Article = z.infer<typeof articleSchema>;
export type CreateArticleInput = z.infer<typeof createArticleSchema>;
export type UpdateArticleInput = z.infer<typeof updateArticleSchema>;
export type ListArticlesQuery = z.infer<typeof listArticlesQuerySchema>;

middleware/validate.ts

import { Request, Response, NextFunction } from 'express';
import { AnyZodObject, ZodError } from 'zod';

export const validate = (schema: AnyZodObject, source: 'body' | 'query' | 'params' = 'body') =>
async (req: Request, res: Response, next: NextFunction) => {
try {
const data = await schema.parseAsync(req[source]);
req[source] = data; // Replace with validated/transformed data
next();
} catch (error) {
if (error instanceof ZodError) {
return res.status(400).json({
error: 'Validation Error',
code: 'VALIDATION_ERROR',
details: error.errors.map((e) => ({
field: e.path.join('.'),
message: e.message,
})),
});
}
next(error);
}
};

routes/articles.ts

import { Router } from 'express';
import { articleController } from '../controllers/articleController';
import { validate } from '../middleware/validate';
import { authenticate } from '../middleware/authenticate';
import { authorize } from '../middleware/authorize';
import {
createArticleSchema,
updateArticleSchema,
listArticlesQuerySchema,
} from '../schemas/articleSchema';

const router = Router();

/**
* @route GET /api/articles
* @desc List articles with pagination and filters
* @access Public
*/
router.get(
'/',
validate(listArticlesQuerySchema, 'query'),
articleController.list
);

/**
* @route GET /api/articles/:id
* @desc Retrieve an article by ID or slug
* @access Public
*/
router.get('/:id', articleController.getById);

/**
* @route POST /api/articles
* @desc Create a new article
* @access Private (author)
*/
router.post(
'/',
authenticate,
authorize('author', 'admin'),
validate(createArticleSchema),
articleController.create
);

/**
* @route PUT /api/articles/:id
* @desc Update an article
* @access Private (owner or admin)
*/
router.put(
'/:id',
authenticate,
validate(updateArticleSchema),
articleController.update
);

/**
* @route DELETE /api/articles/:id
* @desc Delete an article
* @access Private (owner or admin)
*/
router.delete(
'/:id',
authenticate,
authorize('admin'),
articleController.remove
);

/**
* @route POST /api/articles/:id/publish
* @desc Publish an article
* @access Private (owner or admin)
*/
router.post(
'/:id/publish',
authenticate,
articleController.publish
);

export default router;

controllers/articleController.ts

import { Request, Response, NextFunction } from 'express';
import { articleService } from '../services/articleService';
import { ApiError } from '../utils/ApiError';
import type { ListArticlesQuery, CreateArticleInput, UpdateArticleInput } from '../schemas/articleSchema';

export const articleController = {
/**
* List articles
*/
async list(req: Request, res: Response, next: NextFunction) {
try {
const query = req.query as ListArticlesQuery;
const result = await articleService.findAll(query);

res.json({
data: result.articles,
pagination: {
page: query.page,
limit: query.limit,
total: result.total,
totalPages: Math.ceil(result.total / query.limit),
hasMore: query.page * query.limit < result.total,
},
});
} catch (error) {
next(error);
}
},

/**
* Retrieve an article by ID or slug
*/
async getById(req: Request, res: Response, next: NextFunction) {
try {
const { id } = req.params;
const article = await articleService.findByIdOrSlug(id);

if (!article) {
throw new ApiError(404, 'Article not found');
}

// Increment views if article is published
if (article.status === 'published') {
await articleService.incrementViews(article.id);
}

res.json(article);
} catch (error) {
next(error);
}
},

/**
* Create a new article
*/
async create(req: Request, res: Response, next: NextFunction) {
try {
const data = req.body as CreateArticleInput;
const authorId = req.user!.id;

const article = await articleService.create({
...data,
authorId,
});

res.status(201).json(article);
} catch (error) {
next(error);
}
},

/**
* Update an article
*/
async update(req: Request, res: Response, next: NextFunction) {
try {
const { id } = req.params;
const data = req.body as UpdateArticleInput;
const userId = req.user!.id;
const userRole = req.user!.role;

const existing = await articleService.findById(id);

if (!existing) {
throw new ApiError(404, 'Article not found');
}

// Check permissions
if (existing.authorId !== userId && userRole !== 'admin') {
throw new ApiError(403, 'Not authorized to modify this article');
}

const article = await articleService.update(id, data);
res.json(article);
} catch (error) {
next(error);
}
},

/**
* Delete an article
*/
async remove(req: Request, res: Response, next: NextFunction) {
try {
const { id } = req.params;

const existing = await articleService.findById(id);

if (!existing) {
throw new ApiError(404, 'Article not found');
}

await articleService.delete(id);
res.status(204).send();
} catch (error) {
next(error);
}
},

/**
* Publish an article
*/
async publish(req: Request, res: Response, next: NextFunction) {
try {
const { id } = req.params;
const userId = req.user!.id;
const userRole = req.user!.role;

const existing = await articleService.findById(id);

if (!existing) {
throw new ApiError(404, 'Article not found');
}

if (existing.authorId !== userId && userRole !== 'admin') {
throw new ApiError(403, 'Not authorized to publish this article');
}

if (existing.status === 'published') {
throw new ApiError(400, 'Article already published');
}

const article = await articleService.publish(id);
res.json(article);
} catch (error) {
next(error);
}
},
};

services/articleService.ts

import { prisma } from '../lib/prisma';
import { slugify } from '../utils/slugify';
import type { CreateArticleInput, UpdateArticleInput, ListArticlesQuery } from '../schemas/articleSchema';

export const articleService = {
async findAll(query: ListArticlesQuery) {
const { page, limit, status, authorId, tag, search, sortBy, sortOrder } = query;
const skip = (page - 1) * limit;

const where = {
...(status && { status }),
...(authorId && { authorId }),
...(tag && { tags: { has: tag } }),
...(search && {
OR: [
{ title: { contains: search, mode: 'insensitive' as const } },
{ content: { contains: search, mode: 'insensitive' as const } },
],
}),
};

const [articles, total] = await Promise.all([
prisma.article.findMany({
where,
skip,
take: limit,
orderBy: { [sortBy]: sortOrder },
include: {
author: {
select: { id: true, name: true, avatarUrl: true },
},
},
}),
prisma.article.count({ where }),
]);

return { articles, total };
},

async findById(id: string) {
return prisma.article.findUnique({
where: { id },
include: {
author: {
select: { id: true, name: true, avatarUrl: true },
},
},
});
},

async findByIdOrSlug(idOrSlug: string) {
return prisma.article.findFirst({
where: {
OR: [{ id: idOrSlug }, { slug: idOrSlug }],
},
include: {
author: {
select: { id: true, name: true, avatarUrl: true },
},
},
});
},

async create(data: CreateArticleInput & { authorId: string }) {
const slug = slugify(data.title);

// Check slug uniqueness
const existingSlug = await prisma.article.findUnique({
where: { slug },
});

const finalSlug = existingSlug
? `${slug}-${Date.now()}`
: slug;

return prisma.article.create({
data: {
...data,
slug: finalSlug,
excerpt: data.excerpt || data.content.substring(0, 200) + '...',
},
include: {
author: {
select: { id: true, name: true, avatarUrl: true },
},
},
});
},

async update(id: string, data: UpdateArticleInput) {
const updateData: any = { ...data };

// Regenerate the slug if the title changes
if (data.title) {
updateData.slug = slugify(data.title);
}

return prisma.article.update({
where: { id },
data: updateData,
include: {
author: {
select: { id: true, name: true, avatarUrl: true },
},
},
});
},

async delete(id: string) {
return prisma.article.delete({ where: { id } });
},

async publish(id: string) {
return prisma.article.update({
where: { id },
data: {
status: 'published',
publishedAt: new Date(),
},
});
},

async incrementViews(id: string) {
return prisma.article.update({
where: { id },
data: {
views: { increment: 1 },
},
});
},
};

tests/articles.test.ts

import request from 'supertest';
import { app } from '../app';
import { prisma } from '../lib/prisma';
import { generateToken } from '../utils/jwt';

describe('Articles API', () => {
let authToken: string;
let testUserId: string;

beforeAll(async () => {
// Create a test user
const user = await prisma.user.create({
data: {
email: 'test@example.com',
name: 'Test User',
role: 'author',
},
});
testUserId = user.id;
authToken = generateToken(user);
});

afterAll(async () => {
await prisma.article.deleteMany();
await prisma.user.deleteMany();
});

describe('GET /api/articles', () => {
it('returns paginated articles', async () => {
const response = await request(app)
.get('/api/articles')
.query({ page: 1, limit: 10 })
.expect(200);

expect(response.body).toHaveProperty('data');
expect(response.body).toHaveProperty('pagination');
expect(Array.isArray(response.body.data)).toBe(true);
});

it('filters by status', async () => {
await request(app)
.get('/api/articles')
.query({ status: 'published' })
.expect(200);
});

it('searches in title and content', async () => {
await request(app)
.get('/api/articles')
.query({ search: 'test' })
.expect(200);
});
});

describe('POST /api/articles', () => {
it('creates article with valid data', async () => {
const response = await request(app)
.post('/api/articles')
.set('Authorization', `Bearer ${authToken}`)
.send({
title: 'Test Article',
content: 'This is the content of the test article.',
status: 'draft',
})
.expect(201);

expect(response.body.title).toBe('Test Article');
expect(response.body.slug).toBe('test-article');
expect(response.body.authorId).toBe(testUserId);
});

it('returns 401 without authentication', async () => {
await request(app)
.post('/api/articles')
.send({
title: 'Test',
content: 'Content',
status: 'draft',
})
.expect(401);
});

it('returns 400 with invalid data', async () => {
const response = await request(app)
.post('/api/articles')
.set('Authorization', `Bearer ${authToken}`)
.send({
title: 'AB', // Too short
content: 'Short', // Too short
})
.expect(400);

expect(response.body.code).toBe('VALIDATION_ERROR');
});
});

describe('PUT /api/articles/:id', () => {
let articleId: string;

beforeEach(async () => {
const article = await prisma.article.create({
data: {
title: 'Original Title',
slug: 'original-title',
content: 'Original content here.',
status: 'draft',
authorId: testUserId,
},
});
articleId = article.id;
});

it('updates article', async () => {
const response = await request(app)
.put(`/api/articles/${articleId}`)
.set('Authorization', `Bearer ${authToken}`)
.send({ title: 'Updated Title' })
.expect(200);

expect(response.body.title).toBe('Updated Title');
});

it('returns 404 for non-existent article', async () => {
await request(app)
.put('/api/articles/non-existent-id')
.set('Authorization', `Bearer ${authToken}`)
.send({ title: 'Test' })
.expect(404);
});
});

describe('POST /api/articles/:id/publish', () => {
it('publishes draft article', async () => {
const article = await prisma.article.create({
data: {
title: 'Draft Article',
slug: 'draft-article',
content: 'Content to publish.',
status: 'draft',
authorId: testUserId,
},
});

const response = await request(app)
.post(`/api/articles/${article.id}/publish`)
.set('Authorization', `Bearer ${authToken}`)
.expect(200);

expect(response.body.status).toBe('published');
expect(response.body.publishedAt).toBeDefined();
});
});
});

Key points

AspectImplementation
ValidationZod with reusable middleware
ArchitectureRoutes → Controllers → Services
Permissionsauthenticate + authorize middleware
PaginationCursor with complete metadata
TestsSupertest + clean setup/teardown
  • /qa:qa-security - Security audit of the API
  • /doc:doc-api-spec - Generate OpenAPI spec
  • /dev:dev-test - Add more tests

Rate Limiting

Add rate limiting to protect your API:

import rateLimit from 'express-rate-limit';

const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
});

app.use('/api/', limiter);