Apollo GraphQL Federation ile Resim Yüklemek

Bu yazıda Apollo GraphQL Federation kullanılan bir projede AWS S3 veya CloudFlare R2 gibi depolama hizmetlerine resim yükleme işlemini nasıl yapabiliriz bundan bahsedeceğim.

Ertan Özdemir
Published in
7 min readApr 3, 2023

--

Ama öncesinde neden bu yazıyı yazma ihtiyacı duyduğumdan bahsetmek istiyorum. Bu yazı yayınlanmadan yaklaşık 6 ay önce bir e-ticaret sitesi yapmaya karar verdik. Bu sitenin back-end tarafı Node.js üzerine kurulu bir microservice mimarisi ile çalışacaktı böylelikle hem Apollo Federation’ı hem de GraphQL kullanarak nasıl microservice’ler yazılıyormuş öğrenelim dedik. Tabi ki bunun zorluklarını düşünmeden :) Uygulama üzerinden resim yükleme aşamasına geçtiğimiz zaman alnımızdan soğuk terler akmaya çoktan başlamıştı bile çünkü GraphQL’in resim, dosya vs. (multipart/form-data) yüklemek için herhangi bir native type tanımları yoktu. Dolayısıyla bunu ya sizin yapmanız ya da hazır yazılmış olan kütüphaneleri kullanarak kendi projenize dahil etmeniz gerekiyordu. Type tanımlamalarını kendimizin yazacağı kadar bir süremizin olmamasından dolayı hazır bir kütüphane kullanalım diye düşündük. Burada karşımıza çıkan ilk opsiyon ve bildiğim kadarıyla tek opsiyon @profusion/apollo-federation-upload paketiydi. Bu paketi bir güzelce Router’ımıza entegre ettik fakat ikinci yumruk ise tam da burada yüzümüzde patladı. Bu paket henüz Apollo Server v4'den önceki sürüm olan v3 için yazılmıştı. Bu sebeple Apollo Server v3 üzerinde stabil bir şekilde çalışıyordu fakat v4'te kullanmaya çalıştığımız zaman, multipart/form-data verileri Client’tan Router’a gelse de buradan Subgraph’lara gitmiyordu.

Kullandığımız her şey v4 üzerine kuruluydu. Bu yüzden geri adım atmak zaman açısından maliyet doğuracağından dolayı farklı yollar aramaya koyulduk. Federation yeni bir mimari olmasından dolayı bulabildiğimiz kaynaklar oldukça nişti. Epeyce bir zaman araştırma yaptıktan sonra şu makaleye denk geldik; (https://wundergraph.com/blog/graphql_file_uploads_evaluating_the_5_most_common_approaches)

İşte bundan sonra projenin tüm kaderi değişti çünkü artık resim yükleyebilecektik…

Base64

Base64, birçok veri türünün ASCII karakterleri olarak temsil edilmesine izin veren bir kodlama yöntemidir. Bu yöntem, bir veri öğesini 64 farklı karakterle temsil ederek verilerin güvenli bir şekilde aktarılmasını sağlar. — ChatGPT

Resimlerin aktarımını yapabilmek için base64 yolunu kullanmaya karar verdik. Böylelikle resimler Client’tan yüklenmeye başlanacağı zaman ilk önce onları base64 formatına çevirecek daha sonra da server’a gönderecektik. Eğer base64'e çevrilmiş bir verinin neye benzediğini merak ediyorsanız şu şekilde gösterebilirim;

TWVyaGFiYSBiZW4gRXJ0YW4=

Tabi bir resmi base64'e dönüştüreceğimiz zaman bundan çok daha uzun bir string ifade ile karşılaşmış olacağız.

Tam da bu noktada şöyle bir sorun bize el sallıyor, o da Server’a aktarılacak olan verinin boyutu. Base64'e çevrilen bir dosyanın boyutu artar. Bu artış da ortalama %33 gibi bir oran. Yani siz, boyutu, 1MB’lık bir dosyayı yüklemek istediğinizde server’a gidecek olan verinin boyutu 1.33MB’a çıkar. Bu bir kaç resim için tolare edilebilecek bir oran olsa da büyük boyutlu veriler, dosya sayısındaki artış gibi etmenler server ile client arasındaki iletişimi olumsuz etkiler. Bu sebeple çeşitli optimizasyon yollarına gidilmesinde oldukça fayda vardır. Bizim seneryomuz için tek seferde çok fazla dosya yüklemesi yapmayacağımız için base64 kullanmamızın uygun olacağını düşündük. Dolayısıyla server tarafında GraphQL için yeni bir tip tanılaması da yapmamıza gerek kalmadı. String tipini kullanmamız yeterli olacak.

Eğer bu yöntem aklınıza yatmadıysa yukarıda paylaştığım makaledeki diğer yöntemlere de göz atmanızı veya daha iyi çalışacağını düşündüğünüz yöntemleri yorumlarda yazmanızı tavsiye ederim.

Anlatılan Yöntemin Uygulanması

Bu kadar bilgiyle baş ağrıttıktan sonra gelelim bunun uygulanışına;

1- Gateway ve Subgraph’ın Oluşturulması

const { ApolloServer } = require('apollo-server');
const { buildFederatedSchema } = require('@apollo/federation');

const typeDefs = `
type Query {
product(id: ID!): Product
}

type Mutation {
uploadImage(data: String): String
}

type Product @key(fields: "id") {
id: ID!
name: String!
price: Int!
image: String
}
`;

const resolvers = {
Query: {
product(_, { id }) {
return { id, name: "Product " + id, price: 100 };
},
},
Mutation: {
uploadImage(_, { data }){
// Client'tan gelen base64 data'yı burada karşılayacağız.
}
}
Product: {
__resolveReference(product) {
return { id: product.id, name: "Product " + product.id, price: 100 };
}
}
};

const server = new ApolloServer({
schema: buildFederatedSchema([{ typeDefs, resolvers }])
});

server.listen({ port: 4001 }).then(({ url }) => {
console.log(`🚀 Subgraph ready at ${url}`);
});

İlk olarak subgraph’ımızı oluşturduk bunun içerisinde de uploadImage adında bir mutation oluşturduk. Bu mutation’ın görevi client’tan gelen base64 data’yı alıp bunu AWS S3 veya CloudFlare R2 gibi bir uzak depolama merkezine yüklemek. Daha sonra da yüklenilen yerin URL’sini dönmek. Ben, CloudFlare R2 üzerinden anlatacağım ama R2'yi kullanmak için AWS’in SDK’sını kullanacağım. Dolayısıyla sizin senaryonuzda S3 kullanıyor olsanız bile kodlar arasında muhtemelen çok büyük farklar olmayacaktır.

Şimdi de Gateway (Router) kurulumunu da yapalım;

const { ApolloServer } = require('apollo-server');
const { ApolloGateway } = require('@apollo/gateway');

const gateway = new ApolloGateway({
serviceList: [
{ name: 'product', url: 'http://localhost:4001' }
]
});

const server = new ApolloServer({
gateway,
subscriptions: false
});

server.listen({ port: 4000 }).then(({ url }) => {
console.log(`🚀 Gateway ready at ${url}`);
});

Tüm kurulumlar tamam!

2- AWS SDK Kullanarak Resmi Uzak Depolamaya Yükleme

Uzak sunucuya yollamak için AWS SDK’sını kullanarak uzak depolama merkezine yükleme işleminin üstesinden gelen fonksiyonumuzu yazalım;

/upload.js


const AWS = require('aws-sdk');
const s3 = new AWS.S3({
accessKeyId: 'ACCESS_KEY_ID',
secretAccessKey: 'SECRET_ACCESS_KEY'
});


export const uploadToS3 ({data}) {

const uploadParams = {
Bucket: 'YOUR_BUCKET_NAME',
Key: 'ornek',
Body: Buffer.from(data, 'base64'),
ContentType: 'image/jpeg'
};

return s3.upload(uploadParams, function(err, data) {
if (err) {
console.log('Error:', err);
} else {
console.log('Upload Success:', data.Location);
}
});

}

Dikkat ederseniz bize gelen base64 verisini Buffer’a dönüştürdük ve Body içerisinde S3'e yolladık. Buffer, bayt tabanlı işlemler yapmak istediğimizde kullanılır. Bu nedenle, özellikle ağ iletişimi, dosya işleme, şifreleme ve benzeri durumlarda kullanışlıdır. S3'te bizim Buffer kullanmamıza izin veriyor.

Peki ya “ContentType” ?

Burada aklınıza hemen şu soru gelebilir; “Benim farklı tiplerde (MIME type) dosyalarım olabillir. İlla .jpeg mi yüklemeliyim?” Bu sorunun cevabı tabiki de hayır olacaktır. Farklı tiplerde dosyaları da yükleyebilirsiniz (.png, .jpg vs.). Base64'e çevrilmiş olan bir dosyanın MIME type’ını bulmak da mümkün. Bunu yapabilmek için biraz daha kod yazalım;

/helper.js

export const base64MimeType = (encoded: any) => {
let result = null;

if (typeof encoded !== "string") {
return result;
}

const mime = encoded.match(/data:([a-zA-Z0-9]+\/[a-zA-Z0-9-.+]+).*,.*/);

if (mime && mime.length) {
result = mime[1];
}

return result?.toString();
};

Yukarıda yazılan kodda dikkatinizi çekmek istediğim bir husus var. encoded.match() fonksiyonu base64 data’sı içerisinde bir regex pattern’ı arar. Yani base64 data’sı şöyle bir şey içermiyorsa;

data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD…

Hatalı — istenmeyen sonuç döndürmesi kuvvetle muhtemeldir. Türü Base64 olan bir veriden MIME type’ı çekmenin diğer bir yolu da magic number’ları kullanmaktır. Onu da aşağıdaki bağlantıdan inceleyebilirsiniz;

uploadToS3 fonksiyonunu artık şu şekilde değiştirebiliriz;

const AWS = require('aws-sdk');
const {base64MimeType} = require('./helper.js')

const s3 = new AWS.S3({
accessKeyId: 'ACCESS_KEY_ID',
secretAccessKey: 'SECRET_ACCESS_KEY'
});

export const uploadToS3 = async ({data}) => {

const mimeType = base64MimeType(data)

const uploadParams = {
Bucket: 'YOUR_BUCKET_NAME',
Key: 'ornek',
Body: Buffer.from(data, 'base64'),
ContentType: mimeType
};

return await s3.upload(uploadParams);
}

Artık uploadToS3 fonksiyonunu uploadImage resolver’ında kullanabiliriz;

Mutation: {
uploadImage(_, { data }){
const uploadedImage = await uploadToS3({data});
return uploadedImage.Location
}
}

Client Uygulaması

Şimdi de React kullanarak bir Image Upload componenti oluşturalım;

import React, { useState } from "react";

function ImageUploader() {
const [image, setImage] = useState(null);
const [base64, setBase64] = useState(null);

const handleImageUpload = (event) => {};

return (
<div>
<input type="file" onChange={handleImageUpload} />
</div>
);
}

export default ImageUploader;

handleImageUpload fonksiyonu input’tan gelen dosyayı alıp base64'e çevirecek ve bunu sonra back-end’e yollayacak bunun için gerekli kodları da yazalım;

import React, { useState } from "react";


//Base 64 dönüştürücü
const convertToBase64 = (file) => {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.readAsDataURL(file);

fileReader.onload = () => {
resolve(fileReader.result);
};

fileReader.onerror = (error) => {
reject(error);
};
});
};

// Image Upload Component'i
function ImageUploader() {
const [image, setImage] = useState(null);
const [base64, setBase64] = useState(null);

const handleImageUpload = (event) => {
const file = event.target.files[0];

if (!file) {
return;
}

const base64Data = await convertToBase64(file)

};

return (
<div>
<input type="file" onChange={handleImageUpload} />
</div>
);
}

export default ImageUploader;

Gerekli mutation’ları da yazdıktan sonra bitirmiş olacağız;

import React, { useState } from "react";
import { useMutation } from "@apollo/client";


const UPLOAD_IMAGE = gql`
mutation uploadImage($data: String) {
uploadImage(data: $data)
}
`;

//Base 64 dönüştürücü
const convertToBase64 = (file) => {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.readAsDataURL(file);

fileReader.onload = () => {
resolve(fileReader.result);
};

fileReader.onerror = (error) => {
reject(error);
};
});
};

// Image Upload Component'i
function ImageUploader() {
const [image, setImage] = useState(null);
const [base64, setBase64] = useState(null);

const [uploadImage] = useMutation(UPLOAD_IMAGE);

const handleImageUpload = (event) => {
const file = event.target.files[0];

if (!file) {
return;
}

const base64Data = await convertToBase64(file)
await uploadImage({ variables: { data: base64Data } });

};

return (
<div>
<input type="file" onChange={handleImageUpload} />
</div>
);
}

export default ImageUploader;

Sonuç

Gördüğünüz üzere bir resmi base64 kullanarak Apollo Federation’da kullanmak oldukça kolay. Burada dikkat etmeniz gereken hususlar yukarıda da belirttiğim gibi yüklenilen resimlerin boyutları ve sayıları, base64'e çevrilen dosyaların MIME type’larının base64 içerisinde bulunması. Bunun haricinde dosya yüklemek ile ilgili bildiğiniz daha iyi yöntemler var ise yorumlarda belirtin lütfen 😃

Cover Image: Photo by Ellie Adams on Unsplash

--

--