feat: enhance API route naming conventions and structure

- Added comprehensive guidelines for API route naming and structure in `ROUTE_NAMING_BEST_PRACTICES.md`.
- Updated `ARCHITECHTURE.md` to reference the new best practices document.
- Refactored course-related routes to follow new naming conventions, including changes to endpoint paths and validation schemas.
- Removed the deprecated course clone route and integrated cloning functionality directly into the course routes.
- Adjusted various API methods to ensure consistent usage of HTTP methods and parameter structures across the application.
This commit is contained in:
Rotimi Best
2025-12-05 17:24:16 +01:00
parent eff364baa4
commit 4b52906445
34 changed files with 777 additions and 189 deletions
+543
View File
@@ -0,0 +1,543 @@
# API Route Naming and Structure Best Practices
This guide outlines best practices for naming and structuring API routes, inspired by Stripe's API design principles. These conventions ensure consistency, predictability, and maintainability across all API endpoints.
---
## Core Principles
### 1. **RESTful Resource Naming**
Use **plural nouns** for resource names, following REST conventions:
```typescript
// ✅ GOOD - Plural nouns
GET /courses
POST /courses
GET /courses/:id
PUT /courses/:id
DELETE /courses/:id
GET /organizations
POST /organizations
GET /organizations/:id
// ❌ BAD - Singular nouns
GET /course
POST /course
GET /organization
```
**Why?** Resources represent collections. Even when fetching a single item, you're accessing it from a collection.
---
### 2. **URL Structure Hierarchy**
Organize routes hierarchically to reflect resource relationships:
```typescript
// ✅ GOOD - Clear hierarchy
GET /organizations/:orgId/courses
POST /organizations/:orgId/courses
GET /organizations/:orgId/courses/:courseId
PUT /organizations/:orgId/courses/:courseId
GET /courses/:courseId/lessons
POST /courses/:courseId/lessons
GET /courses/:courseId/lessons/:lessonId
// ❌ BAD - Flat structure loses context
GET /courses (which org?)
GET /lessons (which course?)
```
**Exception:** Top-level resources that don't belong to a parent:
```typescript
// ✅ OK - Top-level resources
GET /account
PUT /account/user
GET /account/subscription
```
---
### 3. **HTTP Method Semantics**
Use HTTP methods correctly for their intended purpose:
| Method | Purpose | Idempotent | Response Body |
|--------|---------|------------|---------------|
| `GET` | Retrieve resource(s) | ✅ Yes | Always |
| `POST` | Create new resource | ❌ No | Created resource |
| `PUT` | Replace entire resource | ✅ Yes | Updated resource |
| `PATCH` | Partial update | ✅ Yes | Updated resource |
| `DELETE` | Remove resource | ✅ Yes | Usually empty (204) |
```typescript
// ✅ GOOD - Correct method usage
GET /courses/:id // Retrieve course
POST /courses // Create course
PUT /courses/:id // Replace entire course
PATCH /courses/:id // Partial update
DELETE /courses/:id // Delete course
// ❌ BAD - Wrong method
POST /courses/:id/update // Should be PUT or PATCH
GET /courses/:id/delete // Should be DELETE
POST /courses/:id // Should be PUT/PATCH for updates
```
---
### 4. **Nested Resources**
Nest resources when there's a clear parent-child relationship:
```typescript
// ✅ GOOD - Nested resources
GET /courses/:courseId/lessons
POST /courses/:courseId/lessons
GET /courses/:courseId/lessons/:lessonId
PUT /courses/:courseId/lessons/:lessonId
GET /organizations/:orgId/members
POST /organizations/:orgId/members
DELETE /organizations/:orgId/members/:memberId
// ❌ BAD - Flat structure with IDs
GET /lessons?courseId=123 // Less clear hierarchy
POST /lessons?courseId=123 // Relationship not obvious
```
**When to nest:**
- ✅ Child resource only exists within parent context
- ✅ Child resource ID is unique within parent (e.g., `lessonId` within `courseId`)
- ✅ Operations typically scoped to parent
**When NOT to nest:**
- ❌ Resource can exist independently
- ❌ Resource ID is globally unique
- ❌ Operations span multiple parents
```typescript
// ✅ OK - Independent resource
GET /users/:userId // User exists independently
GET /courses/:courseId // Course exists independently
// ✅ BAD - Unnecessary nesting
GET /organizations/:orgId/users/:userId // User is global, not org-specific
```
---
### 5. **Action Endpoints (Non-Resource Operations)**
For operations that don't map to CRUD, use action verbs as sub-resources:
```typescript
// ✅ GOOD - Action endpoints
POST /courses/:id/clone
POST /courses/:id/publish
POST /courses/:id/unpublish
POST /organizations/:id/invite
POST /subscriptions/:id/cancel
POST /subscriptions/:id/reactivate
// ❌ BAD - Verbs in main path
POST /clone-course/:id
POST /publish-course/:id
GET /get-courses // Redundant verb
```
**Pattern:** `POST /resource/:id/action`
**When to use:**
- ✅ State transitions (publish, unpublish, activate, cancel)
- ✅ Side effects (clone, duplicate, export)
- ✅ Complex operations that don't fit CRUD
---
### 6. **Query Parameters**
Use query parameters for filtering, sorting, pagination, and optional data:
```typescript
// ✅ GOOD - Query parameters
GET /courses?status=published
GET /courses?orgId=123&status=published
GET /courses?page=1&limit=20
GET /courses?sort=created_at&order=desc
GET /courses?search=javascript
GET /organizations?siteName=acme
// ❌ BAD - Path parameters for filters
GET /courses/status/published
GET /courses/page/1
GET /courses/search/javascript
```
**Common query parameter patterns:**
| Purpose | Parameter | Example |
|---------|-----------|---------|
| Filtering | `status`, `type`, `role` | `?status=active&type=course` |
| Search | `q`, `search` | `?q=javascript` |
| Pagination | `page`, `limit`, `offset` | `?page=1&limit=20` |
| Sorting | `sort`, `order` | `?sort=created_at&order=desc` |
| Date range | `startDate`, `endDate` | `?startDate=2024-01-01` |
| Boolean flags | `include`, `expand` | `?include=lessons&expand=metadata` |
---
### 7. **URL Parameter Naming**
Use **camelCase** for URL parameters to match JavaScript conventions:
```typescript
// ✅ GOOD - camelCase
GET /courses/:courseId
GET /organizations/:orgId
GET /users/:userId
GET /lessons/:lessonId
// ❌ BAD - snake_case or kebab-case
GET /courses/:course_id
GET /courses/:course-id
```
**Exception:** When matching database column names exactly:
```typescript
// ✅ OK - Matching database convention
GET /courses/:id // Generic ID
GET /courses?siteName=acme // Matches DB column
```
---
### 8. **Consistent Response Structure**
Maintain consistent response formats across all endpoints:
```typescript
// ✅ GOOD - Consistent structure
// Single resource
{
"success": true,
"data": { ... }
}
// Collection
{
"success": true,
"data": [ ... ],
"pagination": {
"page": 1,
"limit": 20,
"total": 100,
"totalPages": 5
}
}
// Error
{
"success": false,
"error": "Human-readable message",
"code": "ERROR_CODE",
"field": "fieldName" // Optional, for validation errors
}
```
---
### 9. **Versioning Strategy**
Plan for API versioning from the start:
```typescript
// Current (v1 implied)
.route('/courses', courseRouter)
// Future versioning
.route('/v1/courses', courseRouterV1)
.route('/v2/courses', courseRouterV2)
// Or use headers (Stripe style)
// X-API-Version: 2024-01-01
```
**Recommendation:** Start without versioning, add `/v1/` prefix when breaking changes are needed.
---
### 10. **Route Organization in Code**
Organize routes by domain/resource:
```typescript
// ✅ GOOD - Organized by resource
apps/api/src/routes/
├── course/
│ ├── course.ts // Main course routes
│ ├── lesson.ts // Lesson routes
│ ├── clone.ts // Clone action
│ └── index.ts // Re-exports
├── organization/
│ ├── organization.ts // Main org routes
│ ├── team.ts // Team member routes
│ └── index.ts
└── account/
├── account.ts // Account routes
└── index.ts
```
**Route registration:**
```typescript
// apps/api/src/app.ts
import { courseRouter } from '@api/routes/course';
import { organizationRouter } from '@api/routes/organization';
import { accountRouter } from '@api/routes/account';
export const app = new Hono()
.route('/course', courseRouter) // /course/*
.route('/organization', organizationRouter) // /organization/*
.route('/account', accountRouter); // /account/*
```
---
## Complete Examples
### Example 1: Course Management
```typescript
// apps/api/src/routes/course/course.ts
export const courseRouter = new Hono()
// List courses (with filters)
.get('/', authMiddleware, zValidator('query', ZListCourses), async (c) => {
// GET /course?orgId=123&status=published&page=1&limit=20
})
// Get single course
.get('/:courseId', authMiddleware, zValidator('param', ZGetCourse), async (c) => {
// GET /course/:courseId
})
// Create course
.post('/', authMiddleware, zValidator('json', ZCreateCourse), async (c) => {
// POST /course
})
// Update course (full)
.put('/:courseId', authMiddleware, zValidator('json', ZUpdateCourse), async (c) => {
// PUT /course/:courseId
})
// Update course (partial)
.patch('/:courseId', authMiddleware, zValidator('json', ZPatchCourse), async (c) => {
// PATCH /course/:courseId
})
// Delete course
.delete('/:courseId', authMiddleware, zValidator('param', ZDeleteCourse), async (c) => {
// DELETE /course/:courseId
})
// Actions
.post('/:courseId/clone', authMiddleware, zValidator('json', ZCloneCourse), async (c) => {
// POST /course/:courseId/clone
})
.post('/:courseId/publish', authMiddleware, zValidator('param', ZPublishCourse), async (c) => {
// POST /course/:courseId/publish
});
```
### Example 2: Nested Resources
```typescript
// apps/api/src/routes/course/lesson.ts
export const lessonRouter = new Hono()
// List lessons for a course
.get('/', authMiddleware, zValidator('query', ZListLessons), async (c) => {
// GET /course/:courseId/lesson?page=1&limit=20
})
// Get single lesson
.get('/:lessonId', authMiddleware, zValidator('param', ZGetLesson), async (c) => {
// GET /course/:courseId/lesson/:lessonId
})
// Create lesson
.post('/', authMiddleware, zValidator('json', ZCreateLesson), async (c) => {
// POST /course/:courseId/lesson
})
// Update lesson
.put('/:lessonId', authMiddleware, zValidator('json', ZUpdateLesson), async (c) => {
// PUT /course/:courseId/lesson/:lessonId
})
// Delete lesson
.delete('/:lessonId', authMiddleware, zValidator('param', ZDeleteLesson), async (c) => {
// DELETE /course/:courseId/lesson/:lessonId
});
// Register nested route
// apps/api/src/routes/course/course.ts
export const courseRouter = new Hono()
// ... course routes ...
.route('/:courseId/lesson', lessonRouter); // Nested under course
```
### Example 3: Organization with Team
```typescript
// apps/api/src/routes/organization/organization.ts
export const organizationRouter = new Hono()
// List organizations
.get('/', authMiddleware, zValidator('query', ZListOrgs), async (c) => {
// GET /organization?siteName=acme
})
// Get organization
.get('/:orgId', authMiddleware, zValidator('param', ZGetOrg), async (c) => {
// GET /organization/:orgId
})
// Create organization
.post('/', authMiddleware, zValidator('json', ZCreateOrg), async (c) => {
// POST /organization
})
// Update organization
.put('/:orgId', authMiddleware, zValidator('json', ZUpdateOrg), async (c) => {
// PUT /organization/:orgId
})
// Team management (nested)
.get('/:orgId/team', authMiddleware, zValidator('param', ZGetTeam), async (c) => {
// GET /organization/:orgId/team
})
.post('/:orgId/team/invite', authMiddleware, zValidator('json', ZInviteTeam), async (c) => {
// POST /organization/:orgId/team/invite
})
.delete('/:orgId/team/:memberId', authMiddleware, zValidator('param', ZRemoveMember), async (c) => {
// DELETE /organization/:orgId/team/:memberId
});
```
---
## Anti-Patterns to Avoid
### ❌ Don't: Use verbs in resource names
```typescript
// ❌ BAD
GET /get-courses
POST /create-course
POST /update-course/:id
GET /delete-course/:id
// ✅ GOOD
GET /courses
POST /courses
PUT /courses/:id
DELETE /courses/:id
```
### ❌ Don't: Mix singular and plural
```typescript
// ❌ BAD
GET /course
POST /courses
GET /organization
POST /organizations
// ✅ GOOD
GET /courses
POST /courses
GET /organizations
POST /organizations
```
### ❌ Don't: Use query parameters for required identifiers
```typescript
// ❌ BAD
GET /courses?id=123
GET /courses?courseId=123
// ✅ GOOD
GET /courses/:courseId
GET /courses/123
```
### ❌ Don't: Create deep nesting (>3 levels)
```typescript
// ❌ BAD - Too deep
GET /organizations/:orgId/courses/:courseId/lessons/:lessonId/exercises/:exerciseId
// ✅ GOOD - Flatten when appropriate
GET /exercises/:exerciseId?courseId=123&lessonId=456
// Or use a different resource structure
GET /lessons/:lessonId/exercises/:exerciseId
```
### ❌ Don't: Use inconsistent naming
```typescript
// ❌ BAD - Mixed conventions
GET /courses/:course_id
GET /organizations/:orgId
GET /users/:userId
// ✅ GOOD - Consistent camelCase
GET /courses/:courseId
GET /organizations/:orgId
GET /users/:userId
```
---
## Checklist for New Routes
When creating a new route, ensure:
- [ ] Resource name is **plural** (e.g., `/courses`, not `/course`)
- [ ] URL parameters use **camelCase** (e.g., `:courseId`, not `:course_id`)
- [ ] HTTP method matches operation (GET=read, POST=create, PUT=replace, PATCH=update, DELETE=remove)
- [ ] Nested resources reflect parent-child relationships
- [ ] Action endpoints use `POST /resource/:id/action` pattern
- [ ] Query parameters used for filtering, pagination, sorting
- [ ] Route is registered in main `app.ts`
- [ ] Validation schema exists in `packages/utils/src/validation/`
- [ ] Route follows existing patterns in codebase
- [ ] Response format is consistent with other routes
---
## Summary
**Key Takeaways:**
1. **Plural nouns** for resources (`/courses`, `/organizations`)
2. **camelCase** for parameters (`:courseId`, `:orgId`)
3. **Hierarchical nesting** for related resources
4. **HTTP methods** used semantically (GET, POST, PUT, PATCH, DELETE)
5. **Action verbs** as sub-resources for non-CRUD operations
6. **Query parameters** for filtering, pagination, sorting
7. **Consistent structure** across all endpoints
8. **Organized by domain** in code structure
Following these conventions ensures your API is **predictable**, **maintainable**, and **developer-friendly**.
+1 -1
View File
@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as z from 'zod';
const envSchema = z.object({
CLOUDFLARE_ACCESS_KEY: z.string().optional(),
+1 -1
View File
@@ -28,7 +28,7 @@ export const accountRouter = new Hono()
return handleError(c, error, 'Failed to fetch account data');
}
})
.put('/user', authMiddleware, zValidator('json', ZUpdateProfile), async (c) => {
.put('/profile', authMiddleware, zValidator('json', ZUpdateProfile), async (c) => {
const user = c.get('user');
try {
-32
View File
@@ -1,32 +0,0 @@
import type { CloneCourseErrorResponse, CloneCourseResponse } from '@api/types/database';
import { Hono } from '@api/utils/hono';
import { ZCourseClone } from '@cio/utils/validation/course';
import { authMiddleware } from '@api/middlewares/auth';
import { cloneCourse } from '@api/services/course/clone';
import { zValidator } from '@hono/zod-validator';
export const cloneRouter = new Hono().post('/', authMiddleware, zValidator('json', ZCourseClone), async (c) => {
try {
const validatedData = c.req.valid('json');
const { id, title, description, slug, organizationId } = validatedData;
const user = c.get('user');
// Clone the course
const newCourse = await cloneCourse(id, title, user.id, description, slug, organizationId);
const successResponse: CloneCourseResponse = {
success: true,
course: newCourse
};
return c.json(successResponse, 200);
} catch (error) {
console.error('Error cloning course:', error);
const errorResponse: CloneCourseErrorResponse = {
error: 'Failed to clone course',
details: error instanceof Error ? error.message : 'Unknown error'
};
return c.json(errorResponse, 500);
}
});
+76 -31
View File
@@ -1,49 +1,94 @@
import { ZCertificateDownload, ZCourseDownloadContent } from '@cio/utils/validation/course';
import {
ZCertificateDownload,
ZCourseClone,
ZCourseCloneParam,
ZCourseDownloadContent,
ZCourseDownloadParam
} from '@cio/utils/validation/course';
import { Hono } from '@api/utils/hono';
import { cloneRouter } from '@api/routes/course/clone';
import { authMiddleware } from '@api/middlewares/auth';
import { cloneCourse } from '@api/services/course/clone';
import { generateCertificate } from '@api/utils/certificate';
import { generateCoursePdf } from '@api/utils/course';
import { handleError } from '@api/utils/errors';
import { katexRouter } from '@api/routes/course/katex';
import { lessonRouter } from '@api/routes/course/lesson';
import { presignRouter } from '@api/routes/course/presign';
import { zValidator } from '@hono/zod-validator';
export const courseRouter = new Hono()
.post('/download/certificate', zValidator('json', ZCertificateDownload), async (c) => {
const validatedData = c.req.valid('json');
.post(
'/:courseId/download/certificate',
zValidator('param', ZCourseDownloadParam),
zValidator('json', ZCertificateDownload),
async (c) => {
const validatedData = c.req.valid('json');
const result = await generateCertificate(validatedData);
const result = await generateCertificate(validatedData);
c.header('Content-Type', 'application/pdf');
c.header('Content-Disposition', `attachment; filename="certificate-${validatedData.courseName}.pdf"`);
c.header('Content-Type', 'application/pdf');
c.header('Content-Disposition', `attachment; filename="certificate-${validatedData.courseName}.pdf"`);
return c.body(
new ReadableStream({
start(controller) {
controller.enqueue(result);
controller.close();
}
})
);
})
.post('/download/content', zValidator('json', ZCourseDownloadContent), async (c) => {
const validatedData = c.req.valid('json');
return c.body(
new ReadableStream({
start(controller) {
controller.enqueue(result);
controller.close();
}
})
);
}
)
.post(
'/:courseId/download/content',
zValidator('param', ZCourseDownloadParam),
zValidator('json', ZCourseDownloadContent),
async (c) => {
const validatedData = c.req.valid('json');
const pdfBuffer = await generateCoursePdf(validatedData);
const pdfBuffer = await generateCoursePdf(validatedData);
c.header('Content-Type', 'application/pdf');
c.header('Content-Type', 'application/pdf');
return c.body(
new ReadableStream({
start(controller) {
controller.enqueue(pdfBuffer);
controller.close();
}
})
);
})
return c.body(
new ReadableStream({
start(controller) {
controller.enqueue(pdfBuffer);
controller.close();
}
})
);
}
)
.post(
'/:courseId/clone',
authMiddleware,
zValidator('param', ZCourseCloneParam),
zValidator('json', ZCourseClone),
async (c) => {
try {
const { courseId } = c.req.valid('param');
const validatedData = c.req.valid('json');
const { title, description, slug, organizationId } = validatedData;
const user = c.get('user');
// Clone the course
const newCourse = await cloneCourse(courseId, title, user.id, description, slug, organizationId);
return c.json(
{
success: true,
course: newCourse
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to clone course');
}
}
)
.route('/katex', katexRouter)
.route('/lesson', lessonRouter)
.route('/presign', presignRouter)
.route('/clone', cloneRouter);
.route('/presign', presignRouter);
+2 -2
View File
@@ -1,7 +1,7 @@
import { Hono } from '@api/utils/hono';
import * as z from 'zod';
import { Hono } from '@api/utils/hono';
import katex from 'katex';
import { z } from 'zod';
export const katexRouter = new Hono().get('/', async (c) => {
try {
+4 -4
View File
@@ -5,17 +5,17 @@ import { getOrganisationAnalytics } from '@api/services/dash';
import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
export const dashAnalyticsRouter = new Hono().post(
export const dashAnalyticsRouter = new Hono().get(
'/stats',
authMiddleware,
zValidator('json', ZDashStats),
zValidator('query', ZDashStats),
async (c) => {
try {
const { orgId, siteName } = c.req.valid('json');
const { orgId, siteName } = c.req.valid('query');
const result = await getOrganisationAnalytics(orgId, siteName);
return c.json({ success: true, data: result }, 201);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to load organisation analytics');
}
+1 -1
View File
@@ -4,7 +4,7 @@ import { deliverEmail } from '@cio/email';
import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
export const mailRouter = new Hono().post('/send', zValidator('json', ZDeliverEmail), async (c) => {
export const mailRouter = new Hono().post('/', zValidator('json', ZDeliverEmail), async (c) => {
const validatedData = c.req.valid('json');
try {
+1 -1
View File
@@ -5,7 +5,7 @@ import { authMiddleware } from '@api/middlewares/auth';
import { handleError } from '@api/utils/errors';
import { uploadImage } from '@api/services/media';
export const mediaRouter = new Hono().post('/image/upload', authMiddleware, async (c) => {
export const mediaRouter = new Hono().post('/image', authMiddleware, async (c) => {
try {
const body = await c.req.parseBody();
+3 -3
View File
@@ -1,4 +1,4 @@
import { ZOnboardingStep1, ZOnboardingStep2 } from '@cio/utils/validation/onboarding';
import { ZOnboardingCreateOrg, ZOnboardingUpdateMetadata } from '@cio/utils/validation/onboarding';
import { completeOnboarding, createOrganizationWithOwner, updateUserOnboarding } from '@api/services/onboarding';
import { Hono } from '@api/utils/hono';
@@ -7,7 +7,7 @@ import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
export const onboardingRouter = new Hono()
.post('/step1', authMiddleware, zValidator('json', ZOnboardingStep1), async (c) => {
.post('/create-org', authMiddleware, zValidator('json', ZOnboardingCreateOrg), async (c) => {
try {
const user = c.get('user');
const { fullname, orgName, siteName } = c.req.valid('json');
@@ -19,7 +19,7 @@ export const onboardingRouter = new Hono()
return handleError(c, error, 'Failed to create organization');
}
})
.post('/step2', authMiddleware, zValidator('json', ZOnboardingStep2), async (c) => {
.post('/update-metadata', authMiddleware, zValidator('json', ZOnboardingUpdateMetadata), async (c) => {
try {
const user = c.get('user');
const { goal, source, fullname } = c.req.valid('json');