refactor(api): refactor API module with route definitions and response utilities

Restructure the core API module to improve clarity, consistency, and
maintainability:

- Introduce `defineApiRoutes()` helper for declarative route definitions
  with built-in config validation at startup
- Add `apiSuccess()` / `apiError()` response utilities; enforce their
  use across all handlers (core and modules)
- Move auth enforcement to route definitions (`auth: 'public' | 'user' |
  'admin'`), removing manual auth checks from handlers
- Extract core routes into `core-routes.js`; router now has no knowledge
  of specific features
- Rename `nx-route.js` to `route-handler.js` and update package.json
  export accordingly
- Update ARCHITECTURE.md to reflect new API conventions and point to
  `src/core/api/README.md` for details
This commit is contained in:
2026-04-13 15:13:03 -04:00
parent 89741d4460
commit 4ddf834990
25 changed files with 1261 additions and 1185 deletions
+2 -2
View File
@@ -122,7 +122,7 @@ const PostCreatePage = () => {
setFormData(prev => ({ ...prev, [fieldName]: data.key }));
toast.success('Image téléchargée');
} else {
toast.error(data.error || 'Échec du téléchargement');
toast.error(data.message || data.error || 'Échec du téléchargement');
}
} catch (error) {
console.error('Error uploading image:', error);
@@ -178,7 +178,7 @@ const PostCreatePage = () => {
toast.success('Post créé avec succès');
router.push(`/admin/posts/${postType}/list`);
} else {
toast.error(data.error || data.message || 'Échec de la création');
toast.error(data.message || data.error || 'Échec de la création');
}
} catch (error) {
console.error('Error creating post:', error);
+2 -2
View File
@@ -149,7 +149,7 @@ const PostEditPage = () => {
setFormData(prev => ({ ...prev, [fieldName]: data.key }));
toast.success('Image téléchargée');
} else {
toast.error(data.error || 'Échec du téléchargement');
toast.error(data.message || data.error || 'Échec du téléchargement');
}
} catch (error) {
console.error('Error uploading image:', error);
@@ -204,7 +204,7 @@ const PostEditPage = () => {
toast.success('Post mis à jour avec succès');
router.push(`/admin/posts/${postType}/list`);
} else {
toast.error(data.error || data.message || 'Échec de la mise à jour');
toast.error(data.message || data.error || 'Échec de la mise à jour');
}
} catch (error) {
console.error('Error updating post:', error);
+2 -2
View File
@@ -75,7 +75,7 @@ const PostsListPage = () => {
page: data.page || 1
}));
} else {
toast.error(data.error || 'Échec du chargement des posts');
toast.error(data.message || data.error || 'Échec du chargement des posts');
}
} catch (error) {
console.error('Error loading posts:', error);
@@ -101,7 +101,7 @@ const PostsListPage = () => {
toast.success('Post supprimé avec succès');
loadPosts();
} else {
toast.error(data.error || 'Échec de la suppression');
toast.error(data.message || data.error || 'Échec de la suppression');
}
} catch (error) {
console.error('Error deleting post:', error);
+110 -87
View File
@@ -27,12 +27,24 @@ import {
generatePostFilePath,
generateUniqueFilename,
validateUpload,
getFileExtension,
FILE_TYPE_PRESETS,
FILE_SIZE_LIMITS
} from '@zen/core/storage';
/**
* Extension → MIME type map derived from the validated file extension.
* The client-supplied file.type is NEVER trusted — it is an attacker-controlled
* multipart field with no server-side enforcement.
*/
const EXTENSION_TO_MIME = {
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp',
};
import { getPostsConfig, getPostType } from './config.js';
import { fail, warn } from '../../shared/lib/logger.js';
import { defineApiRoutes, apiSuccess, apiError } from '@zen/core/api';
// ============================================================================
// Config
@@ -41,9 +53,10 @@ import { fail, warn } from '../../shared/lib/logger.js';
async function handleGetConfig() {
try {
const config = getPostsConfig();
return { success: true, config };
return apiSuccess({ success: true, config });
} catch (error) {
return { success: false, error: error.message || 'Failed to get config' };
fail(`Posts: error getting config: ${error.message}`);
return apiError('Internal Server Error', 'Failed to get config');
}
}
@@ -55,14 +68,14 @@ async function handleGetPosts(request) {
try {
const url = new URL(request.url);
const postType = url.searchParams.get('type');
if (!postType) return { success: false, error: 'Post type is required' };
if (!getPostType(postType)) return { success: false, error: `Unknown post type: ${postType}` };
if (!postType) return apiError('Bad Request', 'Post type is required');
if (!getPostType(postType)) return apiError('Bad Request', `Unknown post type: ${postType}`);
const id = url.searchParams.get('id');
if (id) {
const post = await getPostById(postType, parseInt(id));
if (!post) return { success: false, error: 'Post not found' };
return { success: true, post };
if (!post) return apiError('Not Found', 'Post not found');
return apiSuccess({ success: true, post });
}
const page = parseInt(url.searchParams.get('page')) || 1;
@@ -83,17 +96,17 @@ async function handleGetPosts(request) {
withRelations
});
return {
return apiSuccess({
success: true,
posts: result.posts,
total: result.pagination.total,
totalPages: result.pagination.totalPages,
page: result.pagination.page,
limit: result.pagination.limit
};
});
} catch (error) {
fail(`Posts: error GET posts: ${error.message}`);
return { success: false, error: error.message || 'Failed to fetch posts' };
return apiError('Internal Server Error', 'Failed to fetch posts');
}
}
@@ -101,19 +114,19 @@ async function handleCreatePost(request) {
try {
const url = new URL(request.url);
const postType = url.searchParams.get('type');
if (!postType) return { success: false, error: 'Post type is required' };
if (!postType) return apiError('Bad Request', 'Post type is required');
const body = await request.json();
const postData = body.post || body;
if (!postData || Object.keys(postData).length === 0) {
return { success: false, error: 'Post data is required' };
return apiError('Bad Request', 'Post data is required');
}
const post = await createPost(postType, postData);
return { success: true, post, message: 'Post created successfully' };
return apiSuccess({ success: true, post, message: 'Post created successfully' });
} catch (error) {
fail(`Posts: error creating post: ${error.message}`);
return { success: false, error: error.message || 'Failed to create post' };
return apiError('Internal Server Error', 'Failed to create post');
}
}
@@ -124,19 +137,18 @@ async function handleUpdatePost(request) {
const postType = url.searchParams.get('type');
const id = url.searchParams.get('id') || body.id;
if (!postType) return { success: false, error: 'Post type is required' };
if (!id) return { success: false, error: 'Post ID is required' };
if (!postType) return apiError('Bad Request', 'Post type is required');
if (!id) return apiError('Bad Request', 'Post ID is required');
const updates = body.post || (({ id: _i, ...rest }) => rest)(body);
if (!updates || Object.keys(updates).length === 0) {
return { success: false, error: 'Update data is required' };
return apiError('Bad Request', 'Update data is required');
}
const typeConfig = getPostType(postType);
// Handle old image cleanup
const existing = await getPostById(postType, parseInt(id));
if (!existing) return { success: false, error: 'Post not found' };
if (!existing) return apiError('Not Found', 'Post not found');
const post = await updatePost(postType, parseInt(id), updates);
@@ -155,10 +167,10 @@ async function handleUpdatePost(request) {
}
}
return { success: true, post, message: 'Post updated successfully' };
return apiSuccess({ success: true, post, message: 'Post updated successfully' });
} catch (error) {
fail(`Posts: error updating post: ${error.message}`);
return { success: false, error: error.message || 'Failed to update post' };
return apiError('Internal Server Error', 'Failed to update post');
}
}
@@ -168,15 +180,15 @@ async function handleDeletePost(request) {
const postType = url.searchParams.get('type');
const id = url.searchParams.get('id');
if (!postType) return { success: false, error: 'Post type is required' };
if (!id) return { success: false, error: 'Post ID is required' };
if (!postType) return apiError('Bad Request', 'Post type is required');
if (!id) return apiError('Bad Request', 'Post ID is required');
const deleted = await deletePost(postType, parseInt(id));
if (!deleted) return { success: false, error: 'Post not found' };
return { success: true, message: 'Post deleted successfully' };
if (!deleted) return apiError('Not Found', 'Post not found');
return apiSuccess({ success: true, message: 'Post deleted successfully' });
} catch (error) {
fail(`Posts: error deleting post: ${error.message}`);
return { success: false, error: 'Failed to delete post' };
return apiError('Internal Server Error', 'Failed to delete post');
}
}
@@ -190,40 +202,50 @@ async function handleUploadImage(request) {
const file = formData.get('file');
const postType = formData.get('type');
if (!file) return { success: false, error: 'No file provided' };
if (!postType) return { success: false, error: 'Post type is required' };
if (!getPostType(postType)) return { success: false, error: `Unknown post type: ${postType}` };
if (!file) return apiError('Bad Request', 'No file provided');
if (!postType) return apiError('Bad Request', 'Post type is required');
if (!getPostType(postType)) return apiError('Bad Request', `Unknown post type: ${postType}`);
// Read the buffer before validation so both magic-byte assertion and
// dangerous-pattern inspection (HTML/SVG/XML denylist) are executed
// against actual file content, not merely the client-supplied metadata.
const buffer = Buffer.from(await file.arrayBuffer());
const validation = validateUpload({
filename: file.name,
size: file.size,
allowedTypes: FILE_TYPE_PRESETS.IMAGES,
maxSize: FILE_SIZE_LIMITS.IMAGE
maxSize: FILE_SIZE_LIMITS.IMAGE,
buffer,
});
if (!validation.valid) {
return { success: false, error: validation.errors.join(', ') };
return apiError('Bad Request', validation.errors.join(', '));
}
const uniqueFilename = generateUniqueFilename(file.name);
const key = generatePostFilePath(postType, Date.now(), uniqueFilename);
const buffer = Buffer.from(await file.arrayBuffer());
// Derive content-type from the validated extension — never from file.type,
// which is fully attacker-controlled.
const ext = getFileExtension(file.name).toLowerCase();
const contentType = EXTENSION_TO_MIME[ext] ?? 'application/octet-stream';
const uploadResult = await uploadImage({
key,
body: buffer,
contentType: file.type,
contentType,
metadata: { originalName: file.name }
});
if (!uploadResult.success) {
return { success: false, error: uploadResult.error || 'Upload failed' };
return apiError('Internal Server Error', 'Upload failed');
}
return { success: true, key: uploadResult.data.key };
return apiSuccess({ success: true, key: uploadResult.data.key });
} catch (error) {
fail(`Posts: error uploading image: ${error.message}`);
return { success: false, error: error.message || 'Upload failed' };
return apiError('Internal Server Error', 'Upload failed');
}
}
@@ -235,13 +257,13 @@ async function handleGetCategories(request) {
try {
const url = new URL(request.url);
const postType = url.searchParams.get('type');
if (!postType) return { success: false, error: 'Post type is required' };
if (!postType) return apiError('Bad Request', 'Post type is required');
const id = url.searchParams.get('id');
if (id) {
const category = await getCategoryById(postType, parseInt(id));
if (!category) return { success: false, error: 'Category not found' };
return { success: true, category };
if (!category) return apiError('Not Found', 'Category not found');
return apiSuccess({ success: true, category });
}
const page = parseInt(url.searchParams.get('page')) || 1;
@@ -260,17 +282,17 @@ async function handleGetCategories(request) {
sortOrder
});
return {
return apiSuccess({
success: true,
categories: result.categories,
total: result.pagination.total,
totalPages: result.pagination.totalPages,
page: result.pagination.page,
limit: result.pagination.limit
};
});
} catch (error) {
fail(`Posts: error GET categories: ${error.message}`);
return { success: false, error: error.message || 'Failed to fetch categories' };
return apiError('Internal Server Error', 'Failed to fetch categories');
}
}
@@ -278,19 +300,19 @@ async function handleCreateCategory(request) {
try {
const url = new URL(request.url);
const postType = url.searchParams.get('type');
if (!postType) return { success: false, error: 'Post type is required' };
if (!postType) return apiError('Bad Request', 'Post type is required');
const body = await request.json();
const categoryData = body.category || body;
if (!categoryData || Object.keys(categoryData).length === 0) {
return { success: false, error: 'Category data is required' };
return apiError('Bad Request', 'Category data is required');
}
const category = await createCategory(postType, categoryData);
return { success: true, category, message: 'Category created successfully' };
return apiSuccess({ success: true, category, message: 'Category created successfully' });
} catch (error) {
fail(`Posts: error creating category: ${error.message}`);
return { success: false, error: error.message || 'Failed to create category' };
return apiError('Internal Server Error', 'Failed to create category');
}
}
@@ -301,22 +323,22 @@ async function handleUpdateCategory(request) {
const postType = url.searchParams.get('type');
const id = url.searchParams.get('id') || body.id;
if (!postType) return { success: false, error: 'Post type is required' };
if (!id) return { success: false, error: 'Category ID is required' };
if (!postType) return apiError('Bad Request', 'Post type is required');
if (!id) return apiError('Bad Request', 'Category ID is required');
const updates = body.category || (({ id: _i, ...rest }) => rest)(body);
if (!updates || Object.keys(updates).length === 0) {
return { success: false, error: 'Update data is required' };
return apiError('Bad Request', 'Update data is required');
}
const existing = await getCategoryById(postType, parseInt(id));
if (!existing) return { success: false, error: 'Category not found' };
if (!existing) return apiError('Not Found', 'Category not found');
const category = await updateCategory(postType, parseInt(id), updates);
return { success: true, category, message: 'Category updated successfully' };
return apiSuccess({ success: true, category, message: 'Category updated successfully' });
} catch (error) {
fail(`Posts: error updating category: ${error.message}`);
return { success: false, error: error.message || 'Failed to update category' };
return apiError('Internal Server Error', 'Failed to update category');
}
}
@@ -326,17 +348,17 @@ async function handleDeleteCategory(request) {
const postType = url.searchParams.get('type');
const id = url.searchParams.get('id');
if (!postType) return { success: false, error: 'Post type is required' };
if (!id) return { success: false, error: 'Category ID is required' };
if (!postType) return apiError('Bad Request', 'Post type is required');
if (!id) return apiError('Bad Request', 'Category ID is required');
await deleteCategory(postType, parseInt(id));
return { success: true, message: 'Category deleted successfully' };
return apiSuccess({ success: true, message: 'Category deleted successfully' });
} catch (error) {
fail(`Posts: error deleting category: ${error.message}`);
if (error.message.includes('Cannot delete')) {
return { success: false, error: error.message };
return apiError('Bad Request', error.message);
}
return { success: false, error: 'Failed to delete category' };
return apiError('Internal Server Error', 'Failed to delete category');
}
}
@@ -348,17 +370,17 @@ async function handleSearchPosts(request) {
try {
const url = new URL(request.url);
const postType = url.searchParams.get('type');
if (!postType) return { success: false, error: 'Post type is required' };
if (!getPostType(postType)) return { success: false, error: `Unknown post type: ${postType}` };
if (!postType) return apiError('Bad Request', 'Post type is required');
if (!getPostType(postType)) return apiError('Bad Request', `Unknown post type: ${postType}`);
const q = url.searchParams.get('q') || '';
const limit = parseInt(url.searchParams.get('limit')) || 20;
const posts = await searchPosts(postType, q, limit);
return { success: true, posts };
return apiSuccess({ success: true, posts });
} catch (error) {
fail(`Posts: error searching posts: ${error.message}`);
return { success: false, error: error.message || 'Failed to search posts' };
return apiError('Internal Server Error', 'Failed to search posts');
}
}
@@ -369,17 +391,18 @@ async function handleSearchPosts(request) {
async function handlePublicGetConfig() {
try {
const config = getPostsConfig();
return { success: true, config };
return apiSuccess({ success: true, config });
} catch (error) {
return { success: false, error: error.message || 'Failed to get config' };
fail(`Posts: error getting public config: ${error.message}`);
return apiError('Internal Server Error', 'Failed to get config');
}
}
async function handlePublicGetPosts(request, params) {
try {
const postType = params?.type;
if (!postType) return { success: false, error: 'Post type is required' };
if (!getPostType(postType)) return { success: false, error: `Unknown post type: ${postType}` };
if (!postType) return apiError('Bad Request', 'Post type is required');
if (!getPostType(postType)) return apiError('Bad Request', `Unknown post type: ${postType}`);
const url = new URL(request.url);
const page = parseInt(url.searchParams.get('page')) || 1;
@@ -398,17 +421,17 @@ async function handlePublicGetPosts(request, params) {
withRelations
});
return {
return apiSuccess({
success: true,
posts: result.posts,
total: result.pagination.total,
totalPages: result.pagination.totalPages,
page: result.pagination.page,
limit: result.pagination.limit
};
});
} catch (error) {
fail(`Posts: error public GET posts: ${error.message}`);
return { success: false, error: error.message || 'Failed to fetch posts' };
return apiError('Internal Server Error', 'Failed to fetch posts');
}
}
@@ -416,27 +439,27 @@ async function handlePublicGetPostBySlug(request, params) {
try {
const postType = params?.type;
const slug = params?.slug;
if (!postType || !slug) return { success: false, error: 'Post type and slug are required' };
if (!postType || !slug) return apiError('Bad Request', 'Post type and slug are required');
const post = await getPostBySlug(postType, slug);
if (!post) return { success: false, error: 'Post not found' };
return { success: true, post };
if (!post) return apiError('Not Found', 'Post not found');
return apiSuccess({ success: true, post });
} catch (error) {
fail(`Posts: error public GET post by slug: ${error.message}`);
return { success: false, error: error.message || 'Failed to fetch post' };
return apiError('Internal Server Error', 'Failed to fetch post');
}
}
async function handlePublicGetCategories(request, params) {
try {
const postType = params?.type;
if (!postType) return { success: false, error: 'Post type is required' };
if (!postType) return apiError('Bad Request', 'Post type is required');
const categories = await getActiveCategories(postType);
return { success: true, categories };
return apiSuccess({ success: true, categories });
} catch (error) {
fail(`Posts: error public GET categories: ${error.message}`);
return { success: false, error: error.message || 'Failed to fetch categories' };
return apiError('Internal Server Error', 'Failed to fetch categories');
}
}
@@ -445,15 +468,15 @@ async function handlePublicGetCategories(request, params) {
// ============================================================================
export default {
routes: [
routes: defineApiRoutes([
// Admin config
{ path: '/admin/posts/config', method: 'GET', handler: handleGetConfig, auth: 'admin' },
// Admin posts
{ path: '/admin/posts/posts', method: 'GET', handler: handleGetPosts, auth: 'admin' },
{ path: '/admin/posts/posts', method: 'POST', handler: handleCreatePost, auth: 'admin' },
{ path: '/admin/posts/posts', method: 'PUT', handler: handleUpdatePost, auth: 'admin' },
{ path: '/admin/posts/posts', method: 'DELETE', handler: handleDeletePost, auth: 'admin' },
{ path: '/admin/posts/posts', method: 'GET', handler: handleGetPosts, auth: 'admin' },
{ path: '/admin/posts/posts', method: 'POST', handler: handleCreatePost, auth: 'admin' },
{ path: '/admin/posts/posts', method: 'PUT', handler: handleUpdatePost, auth: 'admin' },
{ path: '/admin/posts/posts', method: 'DELETE', handler: handleDeletePost, auth: 'admin' },
// Admin image upload
{ path: '/admin/posts/upload-image', method: 'POST', handler: handleUploadImage, auth: 'admin' },
@@ -462,15 +485,15 @@ export default {
{ path: '/admin/posts/search', method: 'GET', handler: handleSearchPosts, auth: 'admin' },
// Admin categories
{ path: '/admin/posts/categories', method: 'GET', handler: handleGetCategories, auth: 'admin' },
{ path: '/admin/posts/categories', method: 'POST', handler: handleCreateCategory, auth: 'admin' },
{ path: '/admin/posts/categories', method: 'PUT', handler: handleUpdateCategory, auth: 'admin' },
{ path: '/admin/posts/categories', method: 'DELETE', handler: handleDeleteCategory, auth: 'admin' },
{ path: '/admin/posts/categories', method: 'GET', handler: handleGetCategories, auth: 'admin' },
{ path: '/admin/posts/categories', method: 'POST', handler: handleCreateCategory, auth: 'admin' },
{ path: '/admin/posts/categories', method: 'PUT', handler: handleUpdateCategory, auth: 'admin' },
{ path: '/admin/posts/categories', method: 'DELETE', handler: handleDeleteCategory, auth: 'admin' },
// Public
{ path: '/posts/config', method: 'GET', handler: handlePublicGetConfig, auth: 'public' },
{ path: '/posts/:type', method: 'GET', handler: handlePublicGetPosts, auth: 'public' },
{ path: '/posts/:type/:slug', method: 'GET', handler: handlePublicGetPostBySlug, auth: 'public' },
{ path: '/posts/config', method: 'GET', handler: handlePublicGetConfig, auth: 'public' },
{ path: '/posts/:type', method: 'GET', handler: handlePublicGetPosts, auth: 'public' },
{ path: '/posts/:type/:slug', method: 'GET', handler: handlePublicGetPostBySlug, auth: 'public' },
{ path: '/posts/:type/categories', method: 'GET', handler: handlePublicGetCategories, auth: 'public' },
]
])
};
@@ -116,7 +116,7 @@ const CategoriesListPage = () => {
page: data.page || 1
}));
} else {
toast.error(data.error || 'Échec du chargement des catégories');
toast.error(data.message || data.error || 'Échec du chargement des catégories');
}
} catch (error) {
console.error('Error loading categories:', error);
@@ -139,7 +139,7 @@ const CategoriesListPage = () => {
toast.success('Catégorie supprimée avec succès');
loadCategories();
} else {
toast.error(data.error || 'Échec de la suppression de la catégorie');
toast.error(data.message || data.error || 'Échec de la suppression de la catégorie');
}
} catch (error) {
console.error('Error deleting category:', error);
@@ -52,7 +52,7 @@ const CategoryCreatePage = () => {
toast.success('Catégorie créée avec succès');
router.push(`/admin/posts/${postType}/categories`);
} else {
toast.error(data.error || 'Échec de la création');
toast.error(data.message || data.error || 'Échec de la création');
}
} catch (error) {
console.error('Error creating category:', error);
@@ -82,7 +82,7 @@ const CategoryEditPage = () => {
toast.success('Catégorie mise à jour avec succès');
router.push(`/admin/posts/${postType}/categories`);
} else {
toast.error(data.error || 'Échec de la mise à jour');
toast.error(data.message || data.error || 'Échec de la mise à jour');
}
} catch (error) {
console.error('Error updating category:', error);