Apollo Server ile Dosya Yükleme İşlemleri

GraphQL API geliştirirken dosya yükleme işlemleri, çoğu geliştiricinin başını ağrıtan konulardan biridir. REST API’larda multipart/form-data ile hallettiğimiz bu işlemi GraphQL’de nasıl yapacağız? Apollo Server bu konuda bize güzel çözümler sunuyor ama yol üzerinde birkaç tuzak var. Bu yazıda gerçek dünya senaryolarıyla Apollo Server üzerinde dosya yükleme işlemlerini baştan sona ele alacağız.

GraphQL’de Dosya Yükleme Nasıl Çalışır?

GraphQL spec’i aslında dosya yüklemeyi doğrudan tanımlamaz. Bu boşluğu doldurmak için graphql-multipart-request-spec adında bir community standardı geliştirilmiştir. Bu spec, HTTP multipart request’lerini GraphQL mutation’larına bağlamak için bir protokol tanımlar.

Apollo Server bu spec’i destekliyor ancak versiyon 3’ten itibaren built-in dosya yükleme desteği kaldırıldı. Bu nedenle Apollo Server 3 ve sonrasında graphql-upload paketini ayrıca kurmanız gerekiyor. Versiyon 2 kullananlar için durum farklı, o versiyonda Upload scalar built-in geliyor.

Temel akış şu şekilde işliyor:

  • İstemci, dosyayı multipart/form-data formatında gönderiyor
  • Apollo Server middleware’i bu isteği parse ediyor
  • Upload scalar, stream, filename, mimetype ve encoding bilgilerini içeren bir obje döndürüyor
  • Bu stream’i alıp istediğiniz yere (disk, S3, veritabanı) yazıyorsunuz

Kurulum ve Temel Yapılandırma

Önce gerekli paketleri kuralım. Apollo Server 4 kullandığımızı varsayarak ilerleyeceğiz.

npm init -y
npm install @apollo/server graphql graphql-upload express cors
npm install --save-dev typescript @types/node ts-node nodemon

Apollo Server 4, Express ile entegrasyon için expressMiddleware kullanıyor. Dosya yükleme için de bu yapıyı kullanacağız çünkü graphql-upload Express middleware olarak çalışıyor.

# tsconfig.json oluşturun
npx tsc --init

Şimdi temel server yapısını kuralım:

mkdir -p src/uploads
touch src/index.ts src/schema.ts src/resolvers.ts
// src/index.ts
import express from 'express';
import cors from 'cors';
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { graphqlUploadExpress } from 'graphql-upload';
import { typeDefs } from './schema';
import { resolvers } from './resolvers';

async function startServer() {
  const app = express();

  const server = new ApolloServer({
    typeDefs,
    resolvers,
  });

  await server.start();

  app.use(cors());

  // graphql-upload middleware'i apolloMiddleware'den ÖNCE gelmelidir
  app.use(
    '/graphql',
    graphqlUploadExpress({ maxFileSize: 10_000_000, maxFiles: 5 }),
    express.json(),
    expressMiddleware(server, {
      context: async ({ req }) => ({ req }),
    })
  );

  app.listen(4000, () => {
    console.log('Server 4000 portunda calisiyor');
    console.log('GraphQL endpoint: http://localhost:4000/graphql');
  });
}

startServer().catch(console.error);

Burada dikkat edilmesi gereken kritik nokta: graphqlUploadExpress middleware’i her zaman expressMiddleware‘den önce tanımlanmalıdır. Sıralamazı ters yaparsanız dosyalar parse edilmeden gelir ve hata alırsınız.

Schema Tanımlamaları

Upload scalar’ı schema’ya dahil etmemiz gerekiyor:

// src/schema.ts
import { gql } from 'graphql-tag';

export const typeDefs = gql`
  scalar Upload

  type File {
    id: ID!
    filename: String!
    mimetype: String!
    path: String!
    size: Int!
    uploadedAt: String!
  }

  type UploadResponse {
    success: Boolean!
    message: String!
    file: File
  }

  type MultipleUploadResponse {
    success: Boolean!
    message: String!
    files: [File!]!
    failedCount: Int!
  }

  type Query {
    files: [File!]!
    file(id: ID!): File
  }

  type Mutation {
    uploadFile(file: Upload!): UploadResponse!
    uploadMultipleFiles(files: [Upload!]!): MultipleUploadResponse!
    uploadProfilePhoto(userId: ID!, photo: Upload!): UploadResponse!
    deleteFile(id: ID!): Boolean!
  }
`;

Resolver’ları Yazalım

Resolver’larda dosya stream’ini alıp diske yazmak için Node.js stream API’sini kullanacağız:

// src/resolvers.ts
import { GraphQLUpload, FileUpload } from 'graphql-upload';
import { createWriteStream, unlink } from 'fs';
import { join } from 'path';
import { v4 as uuidv4 } from 'uuid';
import { promisify } from 'util';

const unlinkAsync = promisify(unlink);

// Gerçek uygulamada bunu veritabanından alırsınız
const filesDatabase: any[] = [];

interface FileRecord {
  id: string;
  filename: string;
  mimetype: string;
  path: string;
  size: number;
  uploadedAt: string;
}

async function processUpload(
  upload: Promise<FileUpload>
): Promise<FileRecord> {
  const { createReadStream, filename, mimetype } = await upload;

  const fileId = uuidv4();
  const extension = filename.split('.').pop();
  const newFilename = `${fileId}.${extension}`;
  const filePath = join(__dirname, '../uploads', newFilename);

  return new Promise((resolve, reject) => {
    let size = 0;
    const stream = createReadStream();

    stream
      .on('data', (chunk: Buffer) => {
        size += chunk.length;
      })
      .pipe(createWriteStream(filePath))
      .on('finish', () => {
        const record: FileRecord = {
          id: fileId,
          filename: newFilename,
          mimetype,
          path: filePath,
          size,
          uploadedAt: new Date().toISOString(),
        };
        filesDatabase.push(record);
        resolve(record);
      })
      .on('error', (error) => {
        unlinkAsync(filePath).catch(console.error);
        reject(error);
      });
  });
}

export const resolvers = {
  Upload: GraphQLUpload,

  Query: {
    files: () => filesDatabase,
    file: (_: any, { id }: { id: string }) =>
      filesDatabase.find((f) => f.id === id),
  },

  Mutation: {
    uploadFile: async (_: any, { file }: { file: Promise<FileUpload> }) => {
      try {
        const fileRecord = await processUpload(file);
        return {
          success: true,
          message: 'Dosya basariyla yuklendi',
          file: fileRecord,
        };
      } catch (error) {
        console.error('Dosya yukleme hatasi:', error);
        return {
          success: false,
          message: 'Dosya yuklenirken hata olustu',
          file: null,
        };
      }
    },

    uploadMultipleFiles: async (
      _: any,
      { files }: { files: Promise<FileUpload>[] }
    ) => {
      const results = await Promise.allSettled(
        files.map((file) => processUpload(file))
      );

      const successfulFiles: FileRecord[] = [];
      let failedCount = 0;

      results.forEach((result) => {
        if (result.status === 'fulfilled') {
          successfulFiles.push(result.value);
        } else {
          failedCount++;
          console.error('Dosya yukleme hatasi:', result.reason);
        }
      });

      return {
        success: failedCount === 0,
        message: `${successfulFiles.length} dosya yuklendi, ${failedCount} basarisiz`,
        files: successfulFiles,
        failedCount,
      };
    },

    uploadProfilePhoto: async (
      _: any,
      { userId, photo }: { userId: string; photo: Promise<FileUpload> }
    ) => {
      const { mimetype } = await photo;

      // Sadece gorsel dosyalara izin ver
      const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
      if (!allowedMimeTypes.includes(mimetype)) {
        return {
          success: false,
          message: 'Sadece JPEG, PNG ve WebP formatları destekleniyor',
          file: null,
        };
      }

      try {
        const fileRecord = await processUpload(photo);
        return {
          success: true,
          message: `Kullanici ${userId} profil fotografi guncellendi`,
          file: fileRecord,
        };
      } catch (error) {
        return {
          success: false,
          message: 'Profil fotografi yuklenirken hata olustu',
          file: null,
        };
      }
    },

    deleteFile: async (_: any, { id }: { id: string }) => {
      const fileIndex = filesDatabase.findIndex((f) => f.id === id);
      if (fileIndex === -1) return false;

      const file = filesDatabase[fileIndex];
      await unlinkAsync(file.path);
      filesDatabase.splice(fileIndex, 1);
      return true;
    },
  },
};

Dosya Boyutu ve Tip Doğrulama

Gerçek dünya senaryolarında dosya validasyonu kritik önem taşır. Sadece MIME type kontrolü yetmez, çünkü kötü niyetli kullanıcılar MIME type’ı manipüle edebilir. Bu nedenle dosya içeriğini de kontrol etmek gerekir:

// src/fileValidator.ts
import { FileUpload } from 'graphql-upload';
import { createReadStream } from 'fs';

const ALLOWED_MIME_TYPES = {
  images: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
  documents: ['application/pdf', 'text/plain', 'application/msword'],
  videos: ['video/mp4', 'video/mpeg', 'video/quicktime'],
};

const MAX_FILE_SIZES = {
  images: 5 * 1024 * 1024,    // 5MB
  documents: 10 * 1024 * 1024, // 10MB
  videos: 100 * 1024 * 1024,   // 100MB
};

export interface ValidationResult {
  valid: boolean;
  error?: string;
  category?: keyof typeof ALLOWED_MIME_TYPES;
}

export function validateFile(
  mimetype: string,
  filename: string
): ValidationResult {
  let category: keyof typeof ALLOWED_MIME_TYPES | undefined;

  for (const [cat, types] of Object.entries(ALLOWED_MIME_TYPES)) {
    if (types.includes(mimetype)) {
      category = cat as keyof typeof ALLOWED_MIME_TYPES;
      break;
    }
  }

  if (!category) {
    return {
      valid: false,
      error: `Desteklenmeyen dosya tipi: ${mimetype}`,
    };
  }

  // Dosya uzantısı ile MIME type uyumunu kontrol et
  const extension = filename.split('.').pop()?.toLowerCase();
  const mimeExtensionMap: Record<string, string[]> = {
    'image/jpeg': ['jpg', 'jpeg'],
    'image/png': ['png'],
    'image/gif': ['gif'],
    'application/pdf': ['pdf'],
    'video/mp4': ['mp4'],
  };

  const expectedExtensions = mimeExtensionMap[mimetype];
  if (expectedExtensions && extension && !expectedExtensions.includes(extension)) {
    return {
      valid: false,
      error: 'Dosya uzantisi ile icerik tipi uyusmuyor',
    };
  }

  return { valid: true, category };
}

export function getMaxFileSize(category: keyof typeof ALLOWED_MIME_TYPES): number {
  return MAX_FILE_SIZES[category];
}

AWS S3 Entegrasyonu

Çoğu production ortamında dosyaları sunucunun lokaline değil, S3 gibi bir object storage’a yüklersiniz. İşte bunun nasıl yapıldığı:

// src/s3Uploader.ts
import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
import { FileUpload } from 'graphql-upload';
import { Readable } from 'stream';
import { v4 as uuidv4 } from 'uuid';

const s3Client = new S3Client({
  region: process.env.AWS_REGION || 'eu-west-1',
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
  },
});

const BUCKET_NAME = process.env.S3_BUCKET_NAME!;

export interface S3UploadResult {
  key: string;
  url: string;
  filename: string;
  mimetype: string;
  size: number;
}

export async function uploadToS3(
  upload: Promise<FileUpload>
): Promise<S3UploadResult> {
  const { createReadStream, filename, mimetype } = await upload;

  const fileId = uuidv4();
  const extension = filename.split('.').pop();
  const key = `uploads/${new Date().getFullYear()}/${fileId}.${extension}`;

  const stream = createReadStream();
  const chunks: Buffer[] = [];

  // Stream'i buffer'a oku
  await new Promise<void>((resolve, reject) => {
    stream.on('data', (chunk: Buffer) => chunks.push(chunk));
    stream.on('end', resolve);
    stream.on('error', reject);
  });

  const buffer = Buffer.concat(chunks);

  const command = new PutObjectCommand({
    Bucket: BUCKET_NAME,
    Key: key,
    Body: buffer,
    ContentType: mimetype,
    ContentLength: buffer.length,
    // Public read için gerekirse:
    // ACL: 'public-read',
  });

  await s3Client.send(command);

  const url = `https://${BUCKET_NAME}.s3.amazonaws.com/${key}`;

  return {
    key,
    url,
    filename,
    mimetype,
    size: buffer.length,
  };
}

export async function deleteFromS3(key: string): Promise<void> {
  const command = new DeleteObjectCommand({
    Bucket: BUCKET_NAME,
    Key: key,
  });

  await s3Client.send(command);
}

S3 entegrasyonu için gerekli paketleri kurmayı unutmayın:

npm install @aws-sdk/client-s3

İstemci Tarafından Test Etme

Apollo Studio’da dosya yüklemeyi doğrudan test edemezsiniz çünkü multipart request gerektiriyor. Bunun için curl veya özel bir istemci kullanmanız gerekiyor:

# Tek dosya yükleme - curl ile test
curl -X POST http://localhost:4000/graphql 
  -H "Content-Type: multipart/form-data" 
  -F 'operations={"query":"mutation UploadFile($file: Upload!) { uploadFile(file: $file) { success message file { id filename size } } }","variables":{"file":null}}' 
  -F 'map={"0":["variables.file"]}' 
  -F '0=@/path/to/your/file.jpg'

# Birden fazla dosya yükleme
curl -X POST http://localhost:4000/graphql 
  -H "Content-Type: multipart/form-data" 
  -F 'operations={"query":"mutation UploadMultipleFiles($files: [Upload!]!) { uploadMultipleFiles(files: $files) { success message failedCount files { id filename size } } }","variables":{"files":[null,null]}}' 
  -F 'map={"0":["variables.files.0"],"1":["variables.files.1"]}' 
  -F '0=@/path/to/file1.jpg' 
  -F '1=@/path/to/file2.png'

React tarafında Apollo Client ile dosya yükleme yapıyorsanız, apollo-upload-client paketini kullanmanız gerekiyor:

npm install apollo-upload-client
// React istemci örneği
import { ApolloClient, InMemoryCache } from '@apollo/client';
import { createUploadLink } from 'apollo-upload-client';

const client = new ApolloClient({
  cache: new InMemoryCache(),
  link: createUploadLink({
    uri: 'http://localhost:4000/graphql',
  }),
});

// Mutation örneği
const UPLOAD_FILE = gql`
  mutation UploadFile($file: Upload!) {
    uploadFile(file: $file) {
      success
      message
      file {
        id
        filename
        size
      }
    }
  }
`;

// Component içinde kullanım
const [uploadFile] = useMutation(UPLOAD_FILE);

const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
  const file = event.target.files?.[0];
  if (!file) return;

  const result = await uploadFile({
    variables: { file },
  });

  console.log(result.data?.uploadFile);
};

Sık Karşılaşılan Hatalar ve Çözümleri

“Request entity too large” hatası: Express’in default body parser limiti 1MB’dır. Bu limiti aşarsanız bu hatayı alırsınız. graphqlUploadExpress middleware’ine maxFileSize parametresi geçmek çözüm sağlar ancak aynı zamanda Express’in kendi limitini de ayarlamanız gerekebilir.

“Cannot set headers after they are sent” hatası: Bu genellikle stream hata yönetiminin eksik olmasından kaynaklanır. Stream’lerin hata event’larını mutlaka dinleyin ve reject ile resolve işlemlerini uygun şekilde yönetin.

CSEM (Content Security) endişeleri: Kullanıcıdan dosya alıyorsanız, yüklenen dosyaları hiçbir zaman sunucu tarafında çalıştırılabilir bir konuma kaydetmeyin. Upload dizinlerini execution yetkilerinden uzak tutun ve dosya adlarını her zaman yeniden oluşturun.

Memory overflow: Büyük dosyaları chunk chunk okumak yerine tamamını buffer’a alırsanız bellek sorunuyla karşılaşırsınız. Stream API’sini düzgün kullanmak bu problemi çözer.

graphql-upload ve CSRF: Apollo Server 4 CSRF koruması ekliyor. Multipart request’lerde bu koruma devreye girebilir. csrfPrevention: false yapabilirsiniz ancak bu güvenlik riskidir. Bunun yerine request’lerinize Apollo-Require-Preflight: true header’ı ekleyin.

Production’da Dikkat Edilmesi Gerekenler

Production ortamında dosya yüklemeyi configure ederken şu noktalara özellikle dikkat etmelisiniz:

  • Rate limiting: Bir kullanıcının dakikada kaç dosya yükleyebileceğini sınırlandırın. express-rate-limit paketi bu işi güzel yapar.
  • Antivirus taraması: Yüklenen dosyaları mutlaka tarayın. ClamAV’ı Node.js’den kullanmak için clamscan paketi mevcuttur.
  • Dosya adı sanitizasyonu: Orijinal dosya adını asla doğrudan kullanmayın. Path traversal saldırılarına karşı savunmasız kalabilirsiniz.
  • CDN entegrasyonu: S3 bucket’ınızın önüne CloudFront koyun, hem performans artar hem de sunucunuza gelen yük azalır.
  • Monitoring: Büyük dosya yüklemeleri sunucunuzu meşgul eder. Timeout değerlerini ve upload sürelerini izleyin.
# Nginx'de upload boyutunu ayarlamak
# /etc/nginx/nginx.conf veya site config dosyasına ekleyin:
# client_max_body_size 50M;

# PM2 ile başlatmak ve log takibi için
pm2 start dist/index.js --name "graphql-upload-server"
pm2 logs graphql-upload-server

Sonuç

Apollo Server ile dosya yükleme işlemleri, doğru araçları ve yapılandırmayı kullandığınızda son derece yönetilebilir bir hal alıyor. Temel akış şu şekilde özetlenebilir: graphql-upload middleware’ini Apollo middleware’inden önce tanımlayın, Upload scalar’ı resolver’larınıza düzgün bağlayın ve stream’leri memory’e tamamını almadan işleyin.

Gerçek dünya senaryolarında disk yerine S3 veya benzeri bir object storage kullanmak, hem ölçeklenebilirlik hem de güvenilirlik açısından çok daha sağlıklıdır. Özellikle birden fazla uygulama sunucusu çalıştırıyorsanız local disk storage bir seçenek bile olamaz.

Validasyon konusunu asla ihmal etmeyin. Kullanıcıdan gelen her dosyayı güvensiz kabul edin, MIME type doğrulamasını hem istemci hem de sunucu tarafında yapın ve dosya boyutu limitlerini hem graphql-upload middleware’inde hem de Nginx/proxy katmanında tanımlayın. GraphQL dosya yükleme konusu başlangıçta karmaşık görünse de bu adımları takip ettiğinizde sağlam ve güvenli bir yapı kurmuş olursunuz.

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir