واجهات GraphQL

رفع الملفات مع GraphQL

20 دقيقة الدرس 15 من 35

معالجة رفع الملفات في GraphQL

يتطلب رفع الملفات في GraphQL معالجة خاصة لأن GraphQL يعمل تقليديًا مع JSON. تتيح مواصفات طلب GraphQL متعدد الأجزاء رفع الملفات من خلال دمج عمليات GraphQL مع multipart/form-data.

مواصفات طلب GraphQL متعدد الأجزاء

تحدد المواصفات كيفية إرسال الملفات جنبًا إلى جنب مع عمليات GraphQL:

POST /graphql Content-Type: multipart/form-data; boundary=----Boundary ------Boundary Content-Disposition: form-data; name="operations" { "query": "mutation($file: Upload!) { uploadFile(file: $file) { url } }", "variables": { "file": null } } ------Boundary Content-Disposition: form-data; name="map" { "0": ["variables.file"] } ------Boundary Content-Disposition: form-data; name="0"; filename="photo.jpg" Content-Type: image/jpeg [binary file data] ------Boundary--
ملاحظة: يربط حقل map مواضع الملفات في الطلب متعدد الأجزاء بمسارات متغيرات GraphQL. يتيح ذلك رفع ملفات متعددة في طلب واحد.

إعداد Apollo Upload Server

تثبيت وتكوين Apollo Upload Server لرفع الملفات:

// تثبيت التبعيات // npm install apollo-server-express graphql-upload const express = require('express'); const { ApolloServer, gql } = require('apollo-server-express'); const { graphqlUploadExpress } = require('graphql-upload'); const { GraphQLUpload } = require('graphql-upload'); // تعريف المخطط مع Upload scalar const typeDefs = gql` scalar Upload type File { filename: String! mimetype: String! encoding: String! url: String! } type Mutation { uploadFile(file: Upload!): File! uploadMultipleFiles(files: [Upload!]!): [File!]! } `; // إنشاء تطبيق Express const app = express(); // إضافة middleware للرفع قبل Apollo Server // يجب أن يأتي قبل applyMiddleware app.use(graphqlUploadExpress({ maxFileSize: 10000000, // 10 ميجابايت maxFiles: 10 })); // إنشاء Apollo Server const server = new ApolloServer({ typeDefs, resolvers, uploads: false // تعطيل معالجة الرفع المدمجة في Apollo Server }); // تطبيق middleware لـ Apollo await server.start(); server.applyMiddleware({ app }); app.listen(4000, () => { console.log('Server running on http://localhost:4000/graphql'); });

محلل رفع ملف واحد

const fs = require('fs'); const path = require('path'); const { v4: uuidv4 } = require('uuid'); const { GraphQLUpload } = require('graphql-upload'); const resolvers = { Upload: GraphQLUpload, Mutation: { uploadFile: async (_, { file }) => { // الحصول على دفق الملف const { createReadStream, filename, mimetype, encoding } = await file; // التحقق من نوع الملف const allowedTypes = ['image/jpeg', 'image/png', 'image/gif']; if (!allowedTypes.includes(mimetype)) { throw new Error('Only JPEG, PNG, and GIF images are allowed'); } // إنشاء اسم ملف فريد const ext = path.extname(filename); const uniqueFilename = `${uuidv4()}${ext}`; // تعريف مسار الرفع const uploadDir = path.join(__dirname, 'uploads'); if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir, { recursive: true }); } const filePath = path.join(uploadDir, uniqueFilename); // إنشاء دفق كتابة وتوجيه الملف const stream = createReadStream(); const writeStream = fs.createWriteStream(filePath); await new Promise((resolve, reject) => { stream .pipe(writeStream) .on('finish', resolve) .on('error', reject); }); // إرجاع معلومات الملف return { filename: uniqueFilename, mimetype, encoding, url: `/uploads/${uniqueFilename}` }; } } };

رفع ملفات متعددة

const resolvers = { Upload: GraphQLUpload, Mutation: { uploadMultipleFiles: async (_, { files }) => { // معالجة جميع الملفات بالتوازي const uploadPromises = files.map(async (file) => { const { createReadStream, filename, mimetype, encoding } = await file; // التحقق من الملف const allowedTypes = [ 'image/jpeg', 'image/png', 'image/gif', 'application/pdf' ]; if (!allowedTypes.includes(mimetype)) { throw new Error(`Invalid file type: ${mimetype}`); } // التحقق من حجم الملف عن طريق قراءة الدفق let fileSize = 0; const maxSize = 5 * 1024 * 1024; // 5 ميجابايت // إنشاء اسم ملف فريد const ext = path.extname(filename); const uniqueFilename = `${uuidv4()}${ext}`; const uploadDir = path.join(__dirname, 'uploads'); const filePath = path.join(uploadDir, uniqueFilename); // حفظ الملف const stream = createReadStream(); const writeStream = fs.createWriteStream(filePath); await new Promise((resolve, reject) => { stream .on('data', (chunk) => { fileSize += chunk.length; if (fileSize > maxSize) { stream.destroy(); fs.unlinkSync(filePath); reject(new Error('File too large')); } }) .pipe(writeStream) .on('finish', resolve) .on('error', reject); }); return { filename: uniqueFilename, mimetype, encoding, url: `/uploads/${uniqueFilename}` }; }); return await Promise.all(uploadPromises); } } };
نصيحة: عالج رفع الملفات المتعددة بالتوازي باستخدام Promise.all() لتحسين الأداء، لكن كن حذرًا من استخدام الذاكرة.

التحقق من صحة الملف

// fileValidation.js class FileValidator { static validateImage(mimetype, filename) { const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; const allowedExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']; if (!allowedTypes.includes(mimetype)) { throw new Error( `Invalid image type. Allowed: ${allowedTypes.join(', ')}` ); } const ext = path.extname(filename).toLowerCase(); if (!allowedExtensions.includes(ext)) { throw new Error( `Invalid file extension. Allowed: ${allowedExtensions.join(', ')}` ); } } static async validateFileSize(stream, maxSize) { let size = 0; return new Promise((resolve, reject) => { stream .on('data', (chunk) => { size += chunk.length; if (size > maxSize) { stream.destroy(); reject(new Error(`File exceeds ${maxSize / 1024 / 1024}MB limit`)); } }) .on('end', () => resolve(size)) .on('error', reject); }); } static validateFilename(filename) { // إزالة الأحرف الخطيرة المحتملة const sanitized = filename.replace(/[^a-zA-Z0-9._-]/g, '_'); return sanitized; } } // الاستخدام في المحلل const resolvers = { Mutation: { uploadFile: async (_, { file }) => { const { createReadStream, filename, mimetype } = await file; // التحقق من نوع الملف FileValidator.validateImage(mimetype, filename); // تنظيف اسم الملف const safeName = FileValidator.validateFilename(filename); // التحقق من حجم الملف const stream = createReadStream(); const maxSize = 5 * 1024 * 1024; // 5 ميجابايت await FileValidator.validateFileSize(stream, maxSize); // حفظ الملف... } } };

الرفع مع البيانات الوصفية

type Mutation { uploadAvatar( file: Upload! userId: ID! ): User! uploadPost( file: Upload! title: String! description: String tags: [String!] ): Post! } type User { id: ID! username: String! avatarUrl: String } type Post { id: ID! title: String! description: String imageUrl: String! tags: [String!]! createdAt: DateTime! } const resolvers = { Mutation: { uploadAvatar: async (_, { file, userId }, context) => { if (!context.user) { throw new AuthenticationError('Authentication required'); } if (context.user.id !== userId && !context.user.isAdmin) { throw new ForbiddenError('Cannot update another user\'s avatar'); } // رفع الملف const { url } = await uploadImage(file, 'avatars'); // تحديث المستخدم const user = await User.findByIdAndUpdate( userId, { avatarUrl: url }, { new: true } ); return user; }, uploadPost: async (_, { file, title, description, tags }, context) => { if (!context.user) { throw new AuthenticationError('Authentication required'); } // رفع الصورة const { url } = await uploadImage(file, 'posts'); // إنشاء المنشور const post = await Post.create({ authorId: context.user.id, title, description, imageUrl: url, tags, createdAt: new Date() }); return post; } } }; // دالة مساعدة async function uploadImage(file, folder) { const { createReadStream, filename, mimetype } = await file; FileValidator.validateImage(mimetype, filename); const ext = path.extname(filename); const uniqueFilename = `${uuidv4()}${ext}`; const uploadDir = path.join(__dirname, 'uploads', folder); if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir, { recursive: true }); } const filePath = path.join(uploadDir, uniqueFilename); const stream = createReadStream(); const writeStream = fs.createWriteStream(filePath); await new Promise((resolve, reject) => { stream.pipe(writeStream).on('finish', resolve).on('error', reject); }); return { filename: uniqueFilename, mimetype, url: `/${folder}/${uniqueFilename}` }; }
تحذير: تحقق دائمًا من أنواع الملفات وأحجامها وأسمائها. لا تثق أبدًا في أسماء الملفات أو أنواع MIME المقدمة من العميل. احفظ الملفات خارج جذر الويب أو استخدم ضوابط الوصول المناسبة.

مثال على الرفع من جانب العميل

// مثال React مع Apollo Client import { gql, useMutation } from '@apollo/client'; const UPLOAD_FILE = gql` mutation UploadFile($file: Upload!) { uploadFile(file: $file) { filename url } } `; function FileUploadForm() { const [uploadFile, { loading, error }] = useMutation(UPLOAD_FILE); const handleFileChange = async (event) => { const file = event.target.files[0]; if (!file) return; try { const { data } = await uploadFile({ variables: { file } }); console.log('Uploaded:', data.uploadFile.url); } catch (err) { console.error('Upload failed:', err); } }; return ( <div> <input type="file" onChange={handleFileChange} disabled={loading} /> {loading && <p>Uploading...</p>} {error && <p>Error: {error.message}</p>} </div> ); } // مثال لملفات متعددة const UPLOAD_MULTIPLE = gql` mutation UploadMultiple($files: [Upload!]!) { uploadMultipleFiles(files: $files) { filename url } } `; function MultipleFileUpload() { const [uploadFiles] = useMutation(UPLOAD_MULTIPLE); const handleChange = async (event) => { const files = Array.from(event.target.files); const { data } = await uploadFiles({ variables: { files } }); console.log('Uploaded:', data.uploadMultipleFiles); }; return ( <input type="file" multiple onChange={handleChange} /> ); }
تمرين:
  1. نفذ mutation لرفع الملفات مع التحقق من الحجم (بحد أقصى 5 ميجابايت) وقيود النوع
  2. أنشئ محلل رفع ملفات متعددة يعالج الصور وينشئ صورًا مصغرة
  3. ابنِ mutation لرفع صورة ملف التعريف الذي يحذف الصورة القديمة قبل حفظ الجديدة
  4. أضف تتبع التقدم لرفع الملفات باستخدام اشتراكات GraphQL
  5. نفذ نظام إدارة الملفات مع عمليات الرفع والحذف والقائمة