واجهات GraphQL
بناء مشروع واجهة برمجة تطبيقات GraphQL (الجزء الثاني)
بناء مشروع واجهة برمجة تطبيقات GraphQL (الجزء الثاني)
في هذا الدرس، سنوسع واجهة برمجة تطبيقات منصة المدونة بميزات متقدمة بما في ذلك محللات المنشورات/التعليقات، والترقيم القائم على المؤشر، وتحميل الملفات، والاشتراكات في الوقت الفعلي، والتحكم في الوصول القائم على الأدوار.
محللات المنشورات مع الميزات المتقدمة
// resolvers/postResolvers.js
const { Post, User, Comment } = require('../models');
const { Op } = require('sequelize');
const slugify = require('slugify');
const postResolvers = {
Query: {
post: async (_, { id, slug }) => {
const where = id ? { id } : { slug };
const post = await Post.findOne({
where,
include: [
{ model: User, as: 'author' },
{ model: Comment, as: 'comments' },
],
});
if (post) {
// زيادة عدد المشاهدات
await post.increment('viewCount');
}
return post;
},
posts: async (_, { limit = 10, offset = 0, status, authorId }) => {
const where = {};
if (status) where.status = status;
if (authorId) where.authorId = authorId;
return Post.findAll({
where,
limit,
offset,
order: [['createdAt', 'DESC']],
include: [{ model: User, as: 'author' }],
});
},
searchPosts: async (_, { query }) => {
return Post.findAll({
where: {
[Op.or]: [
{ title: { [Op.iLike]: `%${query}%` } },
{ content: { [Op.iLike]: `%${query}%` } },
{ tags: { [Op.contains]: [query] } },
],
status: 'PUBLISHED',
},
include: [{ model: User, as: 'author' }],
});
},
},
Mutation: {
createPost: async (_, { input }, { user }) => {
if (!user) throw new Error('Not authenticated');
const slug = slugify(input.title, { lower: true, strict: true });
const post = await Post.create({
...input,
slug,
authorId: user.id,
});
return Post.findByPk(post.id, {
include: [{ model: User, as: 'author' }],
});
},
updatePost: async (_, { id, input }, { user }) => {
if (!user) throw new Error('Not authenticated');
const post = await Post.findByPk(id);
if (!post) throw new Error('Post not found');
// التحقق من الملكية أو دور المسؤول
if (post.authorId !== user.id && user.role !== 'ADMIN') {
throw new Error('Not authorized');
}
if (input.title) {
input.slug = slugify(input.title, { lower: true, strict: true });
}
await post.update(input);
return Post.findByPk(id, {
include: [{ model: User, as: 'author' }],
});
},
deletePost: async (_, { id }, { user }) => {
if (!user) throw new Error('Not authenticated');
const post = await Post.findByPk(id);
if (!post) throw new Error('Post not found');
if (post.authorId !== user.id && user.role !== 'ADMIN') {
throw new Error('Not authorized');
}
await post.destroy();
return true;
},
publishPost: async (_, { id }, { user }) => {
if (!user) throw new Error('Not authenticated');
const post = await Post.findByPk(id);
if (!post) throw new Error('Post not found');
if (post.authorId !== user.id) {
throw new Error('Not authorized');
}
await post.update({
status: 'PUBLISHED',
publishedAt: new Date(),
});
return Post.findByPk(id, {
include: [{ model: User, as: 'author' }],
});
},
},
Post: {
author: async (parent) => {
return User.findByPk(parent.authorId);
},
comments: async (parent) => {
return Comment.findAll({
where: { postId: parent.id, parentId: null },
order: [['createdAt', 'DESC']],
});
},
},
};
module.exports = postResolvers;
الترقيم القائم على المؤشر
تطبيق الترقيم الفعال لمجموعات البيانات الكبيرة:
// schema/typeDefs.js - إضافة أنواع الترقيم
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type PostEdge {
cursor: String!
node: Post!
}
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
extend type Query {
postsPaginated(
first: Int
after: String
status: PostStatus
): PostConnection!
}
// resolvers/paginationResolvers.js
const { Post, User } = require('../models');
const { Op } = require('sequelize');
const paginationResolvers = {
Query: {
postsPaginated: async (_, { first = 10, after, status }) => {
const where = {};
if (status) where.status = status;
// فك تشفير المؤشر (معرف مشفر base64)
if (after) {
const decodedCursor = Buffer.from(after, 'base64').toString('utf-8');
where.id = { [Op.lt]: decodedCursor };
}
// جلب واحد إضافي لتحديد ما إذا كانت هناك صفحة تالية
const posts = await Post.findAll({
where,
limit: first + 1,
order: [['createdAt', 'DESC']],
include: [{ model: User, as: 'author' }],
});
const hasNextPage = posts.length > first;
const nodes = hasNextPage ? posts.slice(0, -1) : posts;
const edges = nodes.map(node => ({
cursor: Buffer.from(node.id).toString('base64'),
node,
}));
const totalCount = await Post.count({ where });
return {
edges,
pageInfo: {
hasNextPage,
hasPreviousPage: !!after,
startCursor: edges[0]?.cursor,
endCursor: edges[edges.length - 1]?.cursor,
},
totalCount,
};
},
},
};
module.exports = paginationResolvers;
المؤشر مقابل الإزاحة: الترقيم القائم على المؤشر أكثر كفاءة لمجموعات البيانات الكبيرة ويتعامل مع تغييرات البيانات في الوقت الفعلي بشكل أفضل من الترقيم القائم على الإزاحة. استخدمه لميزات التمرير اللانهائي.
تطبيق تحميل الملفات
// تثبيت التبعية
npm install graphql-upload
// schema/typeDefs.js
scalar Upload
extend type Mutation {
uploadAvatar(file: Upload!): String!
uploadPostImage(file: Upload!): String!
}
// resolvers/uploadResolvers.js
const { GraphQLUpload } = require('graphql-upload');
const fs = require('fs');
const path = require('path');
const { v4: uuidv4 } = require('uuid');
const uploadResolvers = {
Upload: GraphQLUpload,
Mutation: {
uploadAvatar: async (_, { file }, { user }) => {
if (!user) throw new Error('Not authenticated');
const { createReadStream, filename, mimetype } = await file;
// التحقق من نوع الملف
if (!mimetype.startsWith('image/')) {
throw new Error('Only image files are allowed');
}
// توليد اسم ملف فريد
const fileExt = path.extname(filename);
const uniqueFilename = `${uuidv4()}${fileExt}`;
const uploadPath = path.join(__dirname, '../uploads/avatars', uniqueFilename);
// إنشاء تدفق الكتابة
const stream = createReadStream();
await new Promise((resolve, reject) =>
stream
.pipe(fs.createWriteStream(uploadPath))
.on('finish', resolve)
.on('error', reject)
);
const fileUrl = `/uploads/avatars/${uniqueFilename}`;
// تحديث صورة المستخدم الرمزية
await user.update({ avatar: fileUrl });
return fileUrl;
},
uploadPostImage: async (_, { file }, { user }) => {
if (!user) throw new Error('Not authenticated');
const { createReadStream, filename, mimetype } = await file;
if (!mimetype.startsWith('image/')) {
throw new Error('Only image files are allowed');
}
const fileExt = path.extname(filename);
const uniqueFilename = `${uuidv4()}${fileExt}`;
const uploadPath = path.join(__dirname, '../uploads/posts', uniqueFilename);
const stream = createReadStream();
await new Promise((resolve, reject) =>
stream
.pipe(fs.createWriteStream(uploadPath))
.on('finish', resolve)
.on('error', reject)
);
return `/uploads/posts/${uniqueFilename}`;
},
},
};
module.exports = uploadResolvers;
الاشتراكات في الوقت الفعلي
// تثبيت التبعيات
npm install graphql-subscriptions
// schema/typeDefs.js
type Subscription {
postCreated: Post!
commentAdded(postId: ID!): Comment!
postUpdated(postId: ID!): Post!
}
// pubsub.js - مثيل Pub/Sub
const { PubSub } = require('graphql-subscriptions');
const pubsub = new PubSub();
const POST_CREATED = 'POST_CREATED';
const COMMENT_ADDED = 'COMMENT_ADDED';
const POST_UPDATED = 'POST_UPDATED';
module.exports = { pubsub, POST_CREATED, COMMENT_ADDED, POST_UPDATED };
// resolvers/subscriptionResolvers.js
const { pubsub, POST_CREATED, COMMENT_ADDED, POST_UPDATED } = require('../pubsub');
const subscriptionResolvers = {
Subscription: {
postCreated: {
subscribe: () => pubsub.asyncIterator([POST_CREATED]),
},
commentAdded: {
subscribe: (_, { postId }) => {
return pubsub.asyncIterator([`${COMMENT_ADDED}_${postId}`]);
},
},
postUpdated: {
subscribe: (_, { postId }) => {
return pubsub.asyncIterator([`${POST_UPDATED}_${postId}`]);
},
},
},
};
// التشغيل في التحولات
// بعد إنشاء المنشور:
await pubsub.publish(POST_CREATED, { postCreated: post });
// بعد إضافة تعليق:
await pubsub.publish(`${COMMENT_ADDED}_${postId}`, { commentAdded: comment });
module.exports = subscriptionResolvers;
ملاحظة الإنتاج: للإنتاج، استخدم PubSub المستند إلى Redis (graphql-redis-subscriptions) للتوسع الأفقي عبر مثيلات الخادم المتعددة.
التحكم في الوصول القائم على الأدوار
// utils/authorization.js
const requireAuth = (user) => {
if (!user) {
throw new Error('Authentication required');
}
};
const requireRole = (user, roles) => {
requireAuth(user);
if (!roles.includes(user.role)) {
throw new Error(`Access denied. Required role: ${roles.join(' or ')}`);
}
};
const isOwnerOrAdmin = (user, ownerId) => {
requireAuth(user);
if (user.id !== ownerId && user.role !== 'ADMIN') {
throw new Error('Access denied. You must be the owner or an admin');
}
};
module.exports = { requireAuth, requireRole, isOwnerOrAdmin };
// تطبيق التفويض في المحللات
const { requireAuth, requireRole, isOwnerOrAdmin } = require('../utils/authorization');
createPost: async (_, { input }, { user }) => {
requireRole(user, ['AUTHOR', 'ADMIN']);
// منطق إنشاء المنشور...
},
deletePost: async (_, { id }, { user }) => {
const post = await Post.findByPk(id);
if (!post) throw new Error('Post not found');
isOwnerOrAdmin(user, post.authorId);
// منطق الحذف...
},
توثيق واجهة برمجة التطبيقات مع توجيهات المخطط
// schema/typeDefs.js - إضافة الأوصاف
"""
يمثل منشور مدونة مع المحتوى والبيانات الوصفية ومعلومات المؤلف
"""
type Post {
"معرف فريد"
id: ID!
"عنوان المنشور (الحد الأقصى 200 حرف)"
title: String!
"Slug صديق لعناوين URL لتحسين محركات البحث"
slug: String!
"محتوى المنشور الكامل بصيغة Markdown"
content: String!
"مقتطف قصير (الحد الأقصى 300 حرف)"
excerpt: String
"عنوان URL للصورة المميزة"
featuredImage: String
"حالة النشر"
status: PostStatus!
"مؤلف المنشور"
author: User!
"التعليقات على هذا المنشور"
comments: [Comment!]!
"علامات المنشور للتصنيف"
tags: [String!]!
"عدد مرات مشاهدة المنشور"
viewCount: Int!
"طابع زمني للإنشاء"
createdAt: String!
"طابع زمني لآخر تحديث"
updatedAt: String!
"تاريخ النشر (فارغ إذا كانت مسودة)"
publishedAt: String
}
تمرين عملي:
- نفذ محللات CRUD للتعليقات مع الردود المتداخلة
- اختبر الترقيم القائم على المؤشر مع أكثر من 100 منشور
- قم بتحميل صور الصورة الرمزية وتحقق من تخزين الملفات
- قم بإعداد عميل WebSocket لاختبار الاشتراكات
- اختبر الوصول القائم على الأدوار: المستخدم لا يمكنه إنشاء منشورات، والمؤلف يمكنه
واجهة برمجة تطبيقات كاملة: تدعم واجهة برمجة تطبيقات منصة المدونة الآن عمليات CRUD الكاملة، وتحميل الملفات، والتحديثات في الوقت الفعلي، والترقيم، والتحكم الآمن في الوصول القائم على الأدوار. جاهزة للنشر في الإنتاج!