File Upload di GraphQL — GraphQL

File Upload di GraphQL GraphQL tidak natively support file upload. Ada beberapa pendekatan, masing-masing dengan trade-off. Approach 1: Presigned URL…

GraphQL gak punya type untuk file. Request GraphQL lewat HTTP berupa JSON, dan JSON cuma kenal teks, angka, boolean, null, array, dan objek, gak ada tempat untuk byte gambar. Jadi upload sampul buku butuh jalan lain. Bayangin kamu ingin mengirim foto lewat surat yang cuma boleh berisi huruf. Bisa sih, fotonya ditulis ulang jadi deretan huruf (Base64), tapi suratnya jadi tebal. Cara yang lebih masuk akal: titip fotonya ke gudang lewat pintu belakang, lalu surat cukup berisi nomor raknya.

🧪 Tebakan cepat: Sampul Ronggeng Dukuh Paruk berukuran 1,2 MB dikirim sebagai string Base64 di variable sebuah mutation. Kira-kira berapa besar body request-nya? Jawabannya ada di bagian "Base64: bisa, tapi mahal".

Kenapa GraphQL gak punya type file

Spesifikasi GraphQL cuma punya lima scalar bawaan: Int, Float, String, Boolean, dan ID. Cara mengirim request-nya diatur terpisah (GraphQL over HTTP), dan bentuknya JSON: { "query": "...", "variables": { ... } }. JSON gak punya tipe untuk data biner, jadi file harus diubah jadi teks, dikirim di format lain, atau gak lewat GraphQL sama sekali. Ada tiga pilihan.

Base64: bisa, tapi mahal

Base64 menulis tiap 3 byte jadi 4 huruf. Sampulnya masuk ke variable sebagai string biasa:

mutation SetCover($bookId: ID!, $image: String!) {
  setCoverBase64(bookId: $bookId, image: $image) {
    title
    coverUrl
  }
}

Variable image berisi "/9j/4AAQSkZJRgABAQ..." (file JPEG selalu diawali /9j/ di Base64). File 1.200.000 byte jadi 1.600.000 karakter, jadi body request-nya 1.600.168 byte, sekitar 1,6 MB: sepertiga lebih besar dari filenya. Itu jawaban tebakannya. Seluruh string itu juga harus dibaca server GraphQL sebagai satu JSON, ditaruh di memori, di-decode, baru dikirim ke tempat penyimpanan. Batas body juga ikut bicara: express.json() defaultnya cuma 100 KB, startStandaloneServer Apollo Server 50 MB. Base64 masih wajar untuk file yang sangat kecil, misalnya ikon beberapa KB.

Opsi 1: GraphQL multipart request

GraphQL multipart request spec buatan Jayden Seric mengirim operasi dan file dalam satu request multipart/form-data, seperti form upload biasa. Isinya tiga bagian: operations (query dan variable, dengan file diisi null), map (file mana menggantikan variable mana), lalu filenya:

------tokobuku
Content-Disposition: form-data; name="operations"

{"query":"mutation ($file: Upload!) { uploadCover(bookId: 4, file: $file) { title coverUrl } }","variables":{"file":null}}
------tokobuku
Content-Disposition: form-data; name="map"

{"0":["variables.file"]}
------tokobuku
Content-Disposition: form-data; name="0"; filename="ronggeng.jpg"
Content-Type: image/jpeg

(1.200.000 byte gambar)
------tokobuku--

Di server, paket graphql-upload (versi 18.0.1) membaca bagian-bagian itu dan memberi resolver sebuah scalar Upload:

import graphqlUploadExpress from "graphql-upload/graphqlUploadExpress.mjs";

app.use("/graphql", graphqlUploadExpress({ maxFileSize: 5_000_000, maxFiles: 1 }));

Ada satu jebakan keamanan. multipart/form-data termasuk content type "simple": browser mau mengirimnya dari situs lain tanpa bertanya dulu (tanpa preflight CORS). Itu celah CSRF (Cross-Site Request Forgery): situs jahat bisa membuat browser Sari mengirim mutation atas nama Sari. Karena itu Apollo Server, sejak versi 4, menolak request seperti ini kecuali ada header Apollo-Require-Preflight atau X-Apollo-Operation-Name yang gak kosong. Tanpa header itu, jawabannya status 400:

{
  "errors": [
    {
      "message": "This operation has been blocked as a potential Cross-Site Request Forgery (CSRF). Please either specify a 'content-type' header (with a type that is not one of application/x-www-form-urlencoded, multipart/form-data, text/plain) or provide a non-empty value for one of the following headers: x-apollo-operation-name, apollo-require-preflight\n",
      "extensions": { "code": "BAD_REQUEST" }
    }
  ]
}

Header custom memaksa browser bertanya dulu (preflight), dan situs lain gak akan diizinkan. Jadi client upload mengirim Apollo-Require-Preflight: true. Walau sudah aman, filenya tetap lewat server GraphQL kamu, dan dokumentasi Apollo sendiri menyarankan cara berikutnya.

Opsi 2, yang disarankan: URL presigned

Filenya gak lewat server GraphQL sama sekali. Server cuma memberi izin upload sekali pakai: sebuah URL presigned, alamat ke tempat penyimpanan (S3 atau yang sejenis) yang sudah ditandatangani dan kedaluwarsa dalam beberapa menit. Alurnya tiga langkah:

# Request Lewat server GraphQL (request + response)
1 mutation { createCoverUpload(bookId: 4, contentType: "image/jpeg", size: 1200000) { uploadUrl key expiresAt } } 313 byte
2 PUT file ke uploadUrl, langsung ke storage 0 byte
3 mutation { attachCover(bookId: 4, key: "covers/4-1.jpg") { title coverUrl } } 202 byte

Jawaban langkah 1 dan 3 dari server Toko Buku di Lab:

{ "data": { "createCoverUpload": { "uploadUrl": "https://storage.tokobuku.test/covers/4-1.jpg?X-Amz-Expires=300&X-Amz-Signature=demo", "key": "covers/4-1.jpg", "expiresAt": "2026-10-11T10:05:00Z" } } }
{ "data": { "attachCover": { "title": "Ronggeng Dukuh Paruk", "coverUrl": "https://cdn.tokobuku.test/covers/4-1.jpg" } } }

Resolver langkah 1 dengan AWS SDK versi 3:

import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const s3 = new S3Client({});
const MAX_COVER = 5_000_000; // 5 MB

const resolvers = {
  Mutation: {
    createCoverUpload: async (_, { bookId, contentType, size }) => {
      if (!["image/jpeg", "image/png", "image/webp"].includes(contentType)) {
        throw new GraphQLError("Cover must be a JPEG, PNG or WebP image", { extensions: { code: "BAD_USER_INPUT" } });
      }
      if (size > MAX_COVER) {
        throw new GraphQLError("Cover must be 5 MB or smaller", { extensions: { code: "BAD_USER_INPUT" } });
      }
      const key = `covers/${bookId}-${crypto.randomUUID()}.jpg`; // server yang memilih key
      const command = new PutObjectCommand({ Bucket: "tokobuku-covers", Key: key, ContentType: contentType });
      const uploadUrl = await getSignedUrl(s3, command, { expiresIn: 300 }); // 5 menit
      return { uploadUrl, key, expiresAt: new Date(Date.now() + 300_000).toISOString() };
    },
  },
};

Batas ukuran

Batas harus dijaga di server, karena angka dari client bisa bohong. Dengan URL presigned, createCoverUpload menolak size di atas 5 MB sebelum satu byte pun dikirim, lalu attachCover memeriksa ukuran file sebenarnya di storage (misalnya lewat request HEAD). S3 juga punya presigned POST dengan kondisi content-length-range kalau batasnya ingin dipaksa oleh storage sendiri. Dengan multipart, maxFileSize di graphql-upload memotong file yang kebesaran, dan membaca stream-nya melempar File truncated as it exceeds the 5000000 byte size limit.

⚠️ Jebakan yang sering ketemu

TL;DR: GraphQL gak punya type file karena request-nya JSON, dan JSON gak bisa membawa data biner. Base64 bisa, tapi body jadi sepertiga lebih besar (1,2 MB jadi 1,6 MB) dan semuanya lewat server. GraphQL multipart request (spec Jayden Seric, paket graphql-upload) mengirim file sebagai multipart/form-data dengan bagian operations, map, dan filenya; Apollo Server menolaknya sebagai potensi CSRF kecuali ada header Apollo-Require-Preflight atau X-Apollo-Operation-Name. Cara yang paling skalabel adalah URL presigned: mutation createCoverUpload memberi uploadUrl dan key, client PUT filenya langsung ke storage, lalu attachCover menyambungkannya ke buku. Batas ukuran dijaga di server: sebelum URL diberikan, dan sekali lagi pada file aslinya.

Yang akan kamu pelajari