-
Notifications
You must be signed in to change notification settings - Fork 1
Open
Description
π MEDIUM: API Design Improvements - Inconsistent Response Formats and Missing Standards
Problem Description
The NSX API lacks consistent design patterns, proper versioning, and standardized response formats, making it difficult for clients to integrate and maintain.
Current problematic patterns:
// server/routes/post.ts - Inconsistent response formats
router.get('/post/:id', async (req: Request, res: Response) => {
// ... logic
res.status(200).json(post) // β Direct data return, no wrapper
})
router.post('/create', isAuthorized, async (req: Request, res: Response) => {
// ... logic
res.status(201).json(post) // β Different format than GET
})
// server/routes/user.ts - Different response patterns
router.post('/login', async ({ body }: Request, res: Response) => {
// ... logic
res.status(200).json({ // β Custom object structure
id: user.id,
name: user.name,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
})
})
router.get('/user_count', async (_req: Request, res: Response<Res.GetUserCount>) => {
// ... logic
res.status(200).json({ userCount }) // β Different structure again
})Why This Is Problematic
- Client Integration Issues: Inconsistent response formats confuse API consumers
- Maintenance Difficulties: Hard to maintain and extend API endpoints
- Documentation Challenges: Difficult to generate accurate API documentation
- Versioning Problems: No API versioning strategy for breaking changes
- Error Handling: Inconsistent error responses across endpoints
Current Impact
- Severity: Medium
- Developer Experience: Poor - inconsistent API patterns
- Maintainability: Low - hard to extend and modify
- Documentation: Difficult - no clear API standards
Comprehensive Solution
1. Implement API Versioning
// server/lib/api/versioning.ts
export const API_VERSION = 'v1'
export const API_BASE_PATH = `/api/${API_VERSION}`
// Version-aware route registration
export const registerVersionedRoutes = (app: express.Application) => {
// V1 routes
app.use(`${API_BASE_PATH}/posts`, postRoutes)
app.use(`${API_BASE_PATH}/users`, userRoutes)
app.use(`${API_BASE_PATH}/tweets`, tweetRoutes)
app.use(`${API_BASE_PATH}/stocks`, stockRoutes)
// Legacy routes (deprecated)
app.use('/api/posts', postRoutes)
app.use('/api/users', userRoutes)
app.use('/api/tweets', tweetRoutes)
app.use('/api/stocks', stockRoutes)
}2. Create Standardized Response Types
// server/lib/api/types.ts
export interface ApiResponse<T = any> {
success: boolean
data?: T
error?: string
code?: string
message?: string
meta?: {
pagination?: {
page: number
limit: number
total: number
totalPages: number
hasNext: boolean
hasPrev: boolean
}
filters?: Record<string, any>
sort?: {
field: string
order: 'asc' | 'desc'
}
}
timestamp: string
requestId: string
}
export interface PaginatedResponse<T> extends ApiResponse<T[]> {
meta: {
pagination: {
page: number
limit: number
total: number
totalPages: number
hasNext: boolean
hasPrev: boolean
}
}
}
export interface ErrorResponse extends ApiResponse<never> {
success: false
error: string
code: string
details?: any
}3. Create Response Builder Utilities
// server/lib/api/responseBuilder.ts
import { Response } from 'express'
import { ApiResponse, PaginatedResponse, ErrorResponse } from './types'
export class ResponseBuilder {
private static generateRequestId(): string {
return Math.random().toString(36).substring(2, 15)
}
static success<T>(
res: Response,
data: T,
message?: string,
statusCode: number = 200,
meta?: ApiResponse<T>['meta']
): void {
const response: ApiResponse<T> = {
success: true,
data,
message,
meta,
timestamp: new Date().toISOString(),
requestId: this.generateRequestId()
}
res.status(statusCode).json(response)
}
static created<T>(
res: Response,
data: T,
message: string = 'Resource created successfully'
): void {
this.success(res, data, message, 201)
}
static updated(
res: Response,
message: string = 'Resource updated successfully'
): void {
this.success(res, undefined, message, 200)
}
static deleted(
res: Response,
message: string = 'Resource deleted successfully'
): void {
this.success(res, undefined, message, 200)
}
static paginated<T>(
res: Response,
data: T[],
pagination: {
page: number
limit: number
total: number
},
message?: string
): void {
const totalPages = Math.ceil(pagination.total / pagination.limit)
const response: PaginatedResponse<T> = {
success: true,
data,
message,
meta: {
pagination: {
page: pagination.page,
limit: pagination.limit,
total: pagination.total,
totalPages,
hasNext: pagination.page < totalPages,
hasPrev: pagination.page > 1
}
},
timestamp: new Date().toISOString(),
requestId: this.generateRequestId()
}
res.status(200).json(response)
}
static error(
res: Response,
error: string,
code: string,
statusCode: number = 500,
details?: any
): void {
const response: ErrorResponse = {
success: false,
error,
code,
details,
timestamp: new Date().toISOString(),
requestId: this.generateRequestId()
}
res.status(statusCode).json(response)
}
}4. Create API Documentation with OpenAPI
// server/lib/api/openapi.ts
import { OpenAPIV3 } from 'openapi-types'
export const openApiSpec: OpenAPIV3.Document = {
openapi: '3.0.0',
info: {
title: 'NSX API',
version: '1.0.0',
description: 'API for NSX - Auto post of web page list you read that day',
contact: {
name: 'NSX Team',
email: 'dojce1048@gmail.com'
}
},
servers: [
{
url: 'http://localhost:4000/api/v1',
description: 'Development server'
},
{
url: 'https://nsx.malloc.tokyo/api/v1',
description: 'Production server'
}
],
paths: {
'/posts': {
get: {
summary: 'Get posts list',
description: 'Retrieve a paginated list of posts',
parameters: [
{
name: 'page',
in: 'query',
schema: { type: 'integer', minimum: 1, default: 1 },
description: 'Page number'
},
{
name: 'limit',
in: 'query',
schema: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
description: 'Number of posts per page'
}
],
responses: {
'200': {
description: 'Successful response',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/PaginatedPostsResponse'
}
}
}
}
}
},
post: {
summary: 'Create a new post',
description: 'Create a new blog post',
security: [{ cookieAuth: [] }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/CreatePostRequest'
}
}
}
},
responses: {
'201': {
description: 'Post created successfully',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/PostResponse'
}
}
}
}
}
}
}
},
components: {
securitySchemes: {
cookieAuth: {
type: 'apiKey',
in: 'cookie',
name: 'token'
}
},
schemas: {
Post: {
type: 'object',
properties: {
id: { type: 'integer' },
title: { type: 'string', maxLength: 400 },
body: { type: 'string' },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' }
},
required: ['id', 'title', 'body', 'createdAt', 'updatedAt']
},
CreatePostRequest: {
type: 'object',
properties: {
title: { type: 'string', minLength: 1, maxLength: 400 },
body: { type: 'string', minLength: 1, maxLength: 50000 }
},
required: ['title', 'body']
},
PostResponse: {
allOf: [
{ $ref: '#/components/schemas/ApiResponse' },
{
type: 'object',
properties: {
data: { $ref: '#/components/schemas/Post' }
}
}
]
},
PaginatedPostsResponse: {
allOf: [
{ $ref: '#/components/schemas/ApiResponse' },
{
type: 'object',
properties: {
data: {
type: 'array',
items: { $ref: '#/components/schemas/Post' }
},
meta: {
type: 'object',
properties: {
pagination: {
type: 'object',
properties: {
page: { type: 'integer' },
limit: { type: 'integer' },
total: { type: 'integer' },
totalPages: { type: 'integer' },
hasNext: { type: 'boolean' },
hasPrev: { type: 'boolean' }
}
}
}
}
}
}
]
},
ApiResponse: {
type: 'object',
properties: {
success: { type: 'boolean' },
message: { type: 'string' },
timestamp: { type: 'string', format: 'date-time' },
requestId: { type: 'string' }
},
required: ['success', 'timestamp', 'requestId']
}
}
}
}5. Update Route Implementations
// server/routes/posts.ts
import { ResponseBuilder } from '../lib/api/responseBuilder'
import { asyncHandler } from '../lib/errors/errorHandler'
import { validateBody, validateQuery, validateParams } from '../lib/validation/middleware'
import { postSchemas } from '../lib/validation/schemas'
// Get posts list with standardized pagination
router.get('/',
validateQuery(z.object({
page: z.string().regex(/^\d+$/).transform(Number).default('1'),
limit: z.string().regex(/^\d+$/).transform(Number).default('10').refine(val => val <= 100, 'Limit too high')
})),
asyncHandler(async (req: Request, res: Response) => {
const { page, limit } = req.query as { page: number; limit: number }
try {
const total = await prisma.post.count()
const offset = (page - 1) * limit
const posts = await prisma.post.findMany({
take: limit,
skip: offset,
orderBy: { createdAt: 'desc' },
select: {
id: true,
title: true,
body: true,
createdAt: true,
updatedAt: true
}
})
ResponseBuilder.paginated(res, posts, { page, limit, total }, 'Posts retrieved successfully')
} catch (error) {
throw new DatabaseError('Failed to retrieve posts')
}
})
)
// Get single post
router.get('/:id',
validateParams(z.object({ id: z.string().regex(/^\d+$/) })),
asyncHandler(async (req: Request, res: Response) => {
const postId = parseInt(req.params.id, 10)
const post = await prisma.post.findUnique({
where: { id: postId }
})
if (!post) {
throw new NotFoundError('Post')
}
ResponseBuilder.success(res, post, 'Post retrieved successfully')
})
)
// Create post
router.post('/',
isAuthorized,
validateBody(postSchemas.create),
asyncHandler(async (req: Request, res: Response) => {
const { title, body } = req.body
const post = await prisma.post.create({
data: {
title: title.trim(),
body: body.trim()
}
})
ResponseBuilder.created(res, post, 'Post created successfully')
})
)
// Update post
router.put('/:id',
isAuthorized,
validateParams(z.object({ id: z.string().regex(/^\d+$/) })),
validateBody(postSchemas.update),
asyncHandler(async (req: Request, res: Response) => {
const postId = parseInt(req.params.id, 10)
const { title, body } = req.body
// Check if post exists
const existingPost = await prisma.post.findUnique({
where: { id: postId }
})
if (!existingPost) {
throw new NotFoundError('Post')
}
await prisma.post.update({
where: { id: postId },
data: {
title: title.trim(),
body: body.trim()
}
})
ResponseBuilder.updated(res, 'Post updated successfully')
})
)
// Delete post
router.delete('/:id',
isAuthorized,
validateParams(z.object({ id: z.string().regex(/^\d+$/) })),
asyncHandler(async (req: Request, res: Response) => {
const postId = parseInt(req.params.id, 10)
// Check if post exists
const existingPost = await prisma.post.findUnique({
where: { id: postId }
})
if (!existingPost) {
throw new NotFoundError('Post')
}
await prisma.post.delete({
where: { id: postId }
})
ResponseBuilder.deleted(res, 'Post deleted successfully')
})
)6. Add API Rate Limiting
// server/lib/api/rateLimiting.ts
import rateLimit from 'express-rate-limit'
// General API rate limiting
export const generalRateLimit = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
message: {
success: false,
error: 'Too many requests',
code: 'RATE_LIMIT_EXCEEDED',
message: 'Please try again later'
},
standardHeaders: true,
legacyHeaders: false
})
// Strict rate limiting for auth endpoints
export const authRateLimit = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts per window
message: {
success: false,
error: 'Too many authentication attempts',
code: 'AUTH_RATE_LIMIT_EXCEEDED',
message: 'Please try again later'
}
})
// Rate limiting for write operations
export const writeRateLimit = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 20, // 20 write operations per window
message: {
success: false,
error: 'Too many write operations',
code: 'WRITE_RATE_LIMIT_EXCEEDED',
message: 'Please try again later'
}
})7. Add API Health Check
// server/routes/health.ts
import { Request, Response } from 'express'
import { ResponseBuilder } from '../lib/api/responseBuilder'
// API health check
router.get('/health', (req: Request, res: Response) => {
const health = {
status: 'healthy',
timestamp: new Date().toISOString(),
version: process.env.npm_package_version || '1.0.0',
uptime: process.uptime(),
environment: process.env.NODE_ENV || 'development'
}
ResponseBuilder.success(res, health, 'API is healthy')
})
// Database health check
router.get('/health/database', async (req: Request, res: Response) => {
try {
await prisma.$queryRaw`SELECT 1`
const health = {
status: 'healthy',
database: 'connected',
timestamp: new Date().toISOString()
}
ResponseBuilder.success(res, health, 'Database is healthy')
} catch (error) {
ResponseBuilder.error(
res,
'Database connection failed',
'DATABASE_ERROR',
503
)
}
})8. Add API Documentation Endpoint
// server/routes/docs.ts
import { Request, Response } from 'express'
import { openApiSpec } from '../lib/api/openapi'
// Serve OpenAPI specification
router.get('/openapi.json', (req: Request, res: Response) => {
res.json(openApiSpec)
})
// Serve API documentation
router.get('/docs', (req: Request, res: Response) => {
const html = `
<!DOCTYPE html>
<html>
<head>
<title>NSX API Documentation</title>
<link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@4.15.5/swagger-ui.css" />
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@4.15.5/swagger-ui-bundle.js"></script>
<script>
SwaggerUIBundle({
url: '/api/v1/openapi.json',
dom_id: '#swagger-ui',
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIBundle.presets.standalone
]
})
</script>
</body>
</html>
`
res.send(html)
})Testing the Implementation
1. API Response Format Tests
// tests/api-consistency.test.ts
import request from 'supertest'
import app from '../server/index'
describe('API Consistency', () => {
it('should return consistent response format for all endpoints', async () => {
const response = await request(app)
.get('/api/v1/posts')
.expect(200)
expect(response.body).toMatchObject({
success: expect.any(Boolean),
data: expect.any(Array),
timestamp: expect.any(String),
requestId: expect.any(String)
})
})
it('should include pagination meta for list endpoints', async () => {
const response = await request(app)
.get('/api/v1/posts?page=1&limit=5')
.expect(200)
expect(response.body.meta).toMatchObject({
pagination: {
page: 1,
limit: 5,
total: expect.any(Number),
totalPages: expect.any(Number),
hasNext: expect.any(Boolean),
hasPrev: expect.any(Boolean)
}
})
})
it('should return consistent error format', async () => {
const response = await request(app)
.get('/api/v1/posts/999999')
.expect(404)
expect(response.body).toMatchObject({
success: false,
error: expect.any(String),
code: expect.any(String),
timestamp: expect.any(String),
requestId: expect.any(String)
})
})
})2. API Documentation Tests
// tests/api-docs.test.ts
describe('API Documentation', () => {
it('should serve OpenAPI specification', async () => {
const response = await request(app)
.get('/api/v1/openapi.json')
.expect(200)
expect(response.body).toMatchObject({
openapi: '3.0.0',
info: {
title: 'NSX API',
version: '1.0.0'
}
})
})
it('should serve documentation page', async () => {
const response = await request(app)
.get('/api/v1/docs')
.expect(200)
expect(response.text).toContain('swagger-ui')
})
})Migration Strategy
Phase 1: Response Standardization
- Create response builder utilities
- Update critical endpoints
- Add API versioning
Phase 2: Documentation and Standards
- Add OpenAPI specification
- Create API documentation
- Implement rate limiting
Phase 3: Advanced Features
- Add health checks
- Implement monitoring
- Add API analytics
Monitoring and Alerting
1. API Usage Monitoring
// Track API usage
export const trackApiUsage = (req: Request, res: Response, next: NextFunction) => {
const startTime = Date.now()
res.on('finish', () => {
const duration = Date.now() - startTime
Logger.info('API Request:', {
method: req.method,
url: req.url,
statusCode: res.statusCode,
duration,
ip: req.ip,
userAgent: req.get('user-agent')
})
})
next()
}2. API Performance Alerts
# Example alerting rules
alerts:
- name: "High API Error Rate"
condition: "api_error_rate > 5%"
action: "notify_team"
- name: "Slow API Response"
condition: "avg_response_time > 2000ms"
action: "investigate_performance"References
Priority: π‘ Medium
Estimated Effort: 10-15 hours
Risk if not fixed: Medium - Poor developer experience and maintainability
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels