$npx -y skills add sabahattink/antigravity-fullstack-hq --skill api-design-patternsREST API design, versioning, error responses, pagination, OpenAPI conventions. Use when designing new API endpoints, reviewing API contracts, or setting up Swagger/OpenAPI documentation.
| 1 | # API Design Patterns |
| 2 | |
| 3 | ## URL Structure |
| 4 | |
| 5 | ``` |
| 6 | # Resource naming: plural nouns, lowercase, hyphenated |
| 7 | GET /api/v1/users # list |
| 8 | POST /api/v1/users # create |
| 9 | GET /api/v1/users/:id # read one |
| 10 | PATCH /api/v1/users/:id # partial update |
| 11 | PUT /api/v1/users/:id # full replace |
| 12 | DELETE /api/v1/users/:id # delete |
| 13 | |
| 14 | # Nested resources (max 2 levels) |
| 15 | GET /api/v1/users/:userId/orders |
| 16 | POST /api/v1/users/:userId/orders |
| 17 | GET /api/v1/users/:userId/orders/:orderId |
| 18 | |
| 19 | # Actions that don't fit CRUD — use verbs as sub-resources |
| 20 | POST /api/v1/users/:id/activate |
| 21 | POST /api/v1/orders/:id/cancel |
| 22 | POST /api/v1/auth/refresh |
| 23 | POST /api/v1/auth/logout |
| 24 | ``` |
| 25 | |
| 26 | ## Standard Response Envelope |
| 27 | |
| 28 | ```typescript |
| 29 | // types/api-response.ts |
| 30 | export interface ApiResponse<T> { |
| 31 | success: boolean |
| 32 | data: T | null |
| 33 | error: ApiError | null |
| 34 | meta?: ResponseMeta |
| 35 | } |
| 36 | |
| 37 | export interface ApiError { |
| 38 | code: string // machine-readable, stable: 'USER_NOT_FOUND' |
| 39 | message: string // human-readable |
| 40 | details?: Record<string, string[]> // field validation errors |
| 41 | } |
| 42 | |
| 43 | export interface ResponseMeta { |
| 44 | total: number |
| 45 | page: number |
| 46 | limit: number |
| 47 | pages: number |
| 48 | } |
| 49 | |
| 50 | // Success |
| 51 | { |
| 52 | "success": true, |
| 53 | "data": { "id": 1, "name": "Jane" }, |
| 54 | "error": null |
| 55 | } |
| 56 | |
| 57 | // Error |
| 58 | { |
| 59 | "success": false, |
| 60 | "data": null, |
| 61 | "error": { |
| 62 | "code": "VALIDATION_ERROR", |
| 63 | "message": "Invalid request body", |
| 64 | "details": { |
| 65 | "email": ["Must be a valid email address"], |
| 66 | "password": ["Must be at least 8 characters"] |
| 67 | } |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | // Paginated list |
| 72 | { |
| 73 | "success": true, |
| 74 | "data": [...], |
| 75 | "error": null, |
| 76 | "meta": { "total": 243, "page": 2, "limit": 20, "pages": 13 } |
| 77 | } |
| 78 | ``` |
| 79 | |
| 80 | ## NestJS Response Interceptor |
| 81 | |
| 82 | ```typescript |
| 83 | // common/interceptors/response-transform.interceptor.ts |
| 84 | import { |
| 85 | Injectable, NestInterceptor, ExecutionContext, CallHandler, |
| 86 | } from '@nestjs/common' |
| 87 | import { Observable, map } from 'rxjs' |
| 88 | import { ApiResponse } from '../../types/api-response' |
| 89 | |
| 90 | @Injectable() |
| 91 | export class ResponseTransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> { |
| 92 | intercept(context: ExecutionContext, next: CallHandler<T>): Observable<ApiResponse<T>> { |
| 93 | return next.handle().pipe( |
| 94 | map(data => ({ |
| 95 | success: true, |
| 96 | data, |
| 97 | error: null, |
| 98 | })) |
| 99 | ) |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | // Register globally in main.ts |
| 104 | app.useGlobalInterceptors(new ResponseTransformInterceptor()) |
| 105 | ``` |
| 106 | |
| 107 | ## HTTP Status Codes |
| 108 | |
| 109 | ```typescript |
| 110 | // Use these — don't improvise |
| 111 | const STATUS_CODES = { |
| 112 | // 2xx Success |
| 113 | 200: 'OK', // GET, PATCH, PUT — returned with data |
| 114 | 201: 'Created', // POST — resource created |
| 115 | 204: 'No Content', // DELETE, POST actions with no body |
| 116 | |
| 117 | // 3xx Redirect |
| 118 | 301: 'Moved Permanently', // URL changed |
| 119 | 304: 'Not Modified', // conditional GET, cache valid |
| 120 | |
| 121 | // 4xx Client Error |
| 122 | 400: 'Bad Request', // malformed JSON, invalid params |
| 123 | 401: 'Unauthorized', // not authenticated |
| 124 | 403: 'Forbidden', // authenticated but not authorized |
| 125 | 404: 'Not Found', // resource doesn't exist |
| 126 | 409: 'Conflict', // duplicate email, version conflict |
| 127 | 422: 'Unprocessable', // semantically invalid (business rule) |
| 128 | 429: 'Too Many Requests', // rate limited |
| 129 | |
| 130 | // 5xx Server Error |
| 131 | 500: 'Internal Server Error', // unexpected exception |
| 132 | 502: 'Bad Gateway', // upstream service error |
| 133 | 503: 'Service Unavailable', // overloaded / maintenance |
| 134 | } |
| 135 | ``` |
| 136 | |
| 137 | ## Pagination |
| 138 | |
| 139 | ```typescript |
| 140 | // Query params: consistent naming |
| 141 | // GET /users?page=2&limit=20&sort=createdAt&order=desc |
| 142 | |
| 143 | export class PaginationQueryDto { |
| 144 | @IsOptional() @Type(() => Number) @IsInt() @Min(1) |
| 145 | page: number = 1 |
| 146 | |
| 147 | @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) |
| 148 | limit: number = 20 |
| 149 | |
| 150 | @IsOptional() @IsString() |
| 151 | sort?: string = 'createdAt' |
| 152 | |
| 153 | @IsOptional() @IsIn(['asc', 'desc']) |
| 154 | order?: 'asc' | 'desc' = 'desc' |
| 155 | |
| 156 | @IsOptional() @IsString() @MaxLength(200) |
| 157 | search?: string |
| 158 | } |
| 159 | |
| 160 | // Response with cursor-based pagination (for feeds / infinite scroll) |
| 161 | export interface CursorPage<T> { |
| 162 | data: T[] |
| 163 | nextCursor: string | null // opaque, base64 encoded |
| 164 | hasMore: boolean |
| 165 | } |
| 166 | |
| 167 | // Encode/decode cursor |
| 168 | function encodeCursor(payload: object): string { |
| 169 | return Buffer.from(JSON.stringify(payload)).toString('base64url') |
| 170 | } |
| 171 | function decodeCursor(cursor: string): unknown { |
| 172 | return JSON.parse(Buffer.from(cursor, 'base64url').toString()) |
| 173 | } |
| 174 | ``` |
| 175 | |
| 176 | ## API Versioning |
| 177 | |
| 178 | ```typescript |
| 179 | // main.ts — URI versioning (recommended for breaking changes) |
| 180 | import { VersioningType } from '@nestjs/common' |
| 181 | |
| 182 | app.enableVersioning({ type: VersioningType.URI }) |
| 183 | |
| 184 | // Controller |
| 185 | @Controller({ path: 'users', version: '1' }) |
| 186 | exp |