واجهات GraphQL
رفع الملفات مع GraphQL
معالجة رفع الملفات في 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}
/>
);
}
تمرين:
- نفذ mutation لرفع الملفات مع التحقق من الحجم (بحد أقصى 5 ميجابايت) وقيود النوع
- أنشئ محلل رفع ملفات متعددة يعالج الصور وينشئ صورًا مصغرة
- ابنِ mutation لرفع صورة ملف التعريف الذي يحذف الصورة القديمة قبل حفظ الجديدة
- أضف تتبع التقدم لرفع الملفات باستخدام اشتراكات GraphQL
- نفذ نظام إدارة الملفات مع عمليات الرفع والحذف والقائمة