Skip to content

πŸ”Œ MEDIUM: API Design Improvements - Inconsistent Response Formats and Missing StandardsΒ #3592

@ryota-murakami

Description

@ryota-murakami

πŸ”Œ 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

  1. Client Integration Issues: Inconsistent response formats confuse API consumers
  2. Maintenance Difficulties: Hard to maintain and extend API endpoints
  3. Documentation Challenges: Difficult to generate accurate API documentation
  4. Versioning Problems: No API versioning strategy for breaking changes
  5. 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

  1. Create response builder utilities
  2. Update critical endpoints
  3. Add API versioning

Phase 2: Documentation and Standards

  1. Add OpenAPI specification
  2. Create API documentation
  3. Implement rate limiting

Phase 3: Advanced Features

  1. Add health checks
  2. Implement monitoring
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions