Hasura ile MongoDB Remote Schema Entegrasyonu
MongoDB tarafında yıllarca çalışmış biri olarak şunu açıkça söyleyeyim: Hasura’nın PostgreSQL entegrasyonu her zaman “sihirli” hissettirdi, ama MongoDB tarafı uzun süre bu büyüden yoksundu. Remote Schema özelliği bu boşluğu doldurmak için var ve doğru kurulduğunda gerçekten güçlü bir araç haline geliyor. Bugün production ortamında kullandığım bir kurulumu adım adım ele alacağız.
Remote Schema Nedir ve Neden MongoDB için Gerekli?
Hasura temelde ilişkisel veritabanlarında mükemmel çalışır. Tablolarınızı okur, GraphQL şemasını otomatik üretir, permissions, relationships, subscriptions… hepsi hazır gelir. MongoDB içinse hikaye biraz farklı.
Hasura’nın native MongoDB desteği belirli versiyonlardan itibaren gelmeye başladı, ancak Remote Schema yaklaşımı daha fazla esneklik sunuyor çünkü MongoDB veriniz üzerinde custom bir GraphQL server yazıyorsunuz ve bunu Hasura’ya bağlıyorsunuz. Bu “aradaki katman” sizi kısıtlamak yerine özgürleştiriyor: aggregation pipeline’larınızı tam kontrol ediyorsunuz, karmaşık lookup operasyonlarını istediğiniz gibi modelleyebiliyorsunuz, ve Hasura’nın diğer kaynaklarıyla (PostgreSQL, REST endpoints) aynı GraphQL üzerinden birleştirebiliyorsunuz.
Tipik senaryo şu şekilde:
- E-ticaret platformu: Ürün kataloğu MongoDB’de, siparişler ve kullanıcılar PostgreSQL’de
- İçerik yönetimi: Makaleler ve medya MongoDB’de, yetkilendirme sistemi PostgreSQL’de
- IoT/telemetri: Sensör verileri MongoDB’de, cihaz yönetimi ilişkisel DB’de
Bu yapılarda tek bir GraphQL endpoint üzerinden her ikisine de ulaşmak istiyorsunuz. Remote Schema tam olarak bunu sağlıyor.
Temel Mimari
Kurulumun büyük resmine bakalım. Elimizde üç ana bileşen var:
Hasura GraphQL Engine – Ana orkestratör, tüm istekleri karşılıyor
MongoDB GraphQL Server – Yazdığımız custom server, MongoDB’e bağlanıyor ve bir GraphQL endpoint sunuyor
MongoDB – Verinin gerçekte yaşadığı yer
Hasura bu custom server’ı bir “Remote Schema” olarak tanıyor ve introspection ile şemayı alıyor. Sonrasında gelen her ilgili query/mutation’ı bu server’a yönlendiriyor.
Ortam Kurulumu
Önce Docker Compose ile çalışma ortamını ayağa kaldıralım. Bu yapıyı development ortamı olarak kullanıyorum, production için bazı değişiklikler gerekecek ama temel mantık aynı.
# docker-compose.yml
version: '3.8'
services:
mongodb:
image: mongo:6.0
ports:
- "27017:27017"
environment:
MONGO_INITDB_ROOT_USERNAME: admin
MONGO_INITDB_ROOT_PASSWORD: secretpass
MONGO_INITDB_DATABASE: productdb
volumes:
- mongo_data:/data/db
hasura:
image: hasura/graphql-engine:v2.36.0
ports:
- "8080:8080"
environment:
HASURA_GRAPHQL_METADATA_DATABASE_URL: postgres://postgres:postgrespass@postgres:5432/postgres
HASURA_GRAPHQL_ENABLE_CONSOLE: "true"
HASURA_GRAPHQL_DEV_MODE: "true"
HASURA_GRAPHQL_ADMIN_SECRET: myadminsecretkey
depends_on:
- postgres
postgres:
image: postgres:15
environment:
POSTGRES_PASSWORD: postgrespass
volumes:
- pg_data:/var/lib/postgresql/data
mongo-graphql-server:
build: ./mongo-graphql-server
ports:
- "4000:4000"
environment:
MONGODB_URI: mongodb://admin:secretpass@mongodb:27017/productdb?authSource=admin
PORT: 4000
depends_on:
- mongodb
volumes:
mongo_data:
pg_data:
# Ortamı başlatmak için
docker-compose up -d
# Logları takip etmek için
docker-compose logs -f mongo-graphql-server
MongoDB GraphQL Server Yazımı
Şimdi işin et ve kemiğine gelelim. Node.js ile Apollo Server kullanarak MongoDB’yi expose eden bir GraphQL server yazacağız. ./mongo-graphql-server dizinini oluşturun.
mkdir mongo-graphql-server && cd mongo-graphql-server
npm init -y
npm install apollo-server graphql mongoose dotenv
npm install --save-dev nodemon
Önce Mongoose modellerimizi tanımlayalım. E-ticaret senaryosu üzerinden gidiyoruz:
// models/Product.js
const mongoose = require('mongoose');
const reviewSchema = new mongoose.Schema({
userId: String,
rating: Number,
comment: String,
createdAt: { type: Date, default: Date.now }
});
const productSchema = new mongoose.Schema({
sku: { type: String, required: true, unique: true },
name: { type: String, required: true },
description: String,
price: { type: Number, required: true },
currency: { type: String, default: 'TRY' },
category: String,
tags: [String],
stock: { type: Number, default: 0 },
images: [String],
reviews: [reviewSchema],
metadata: mongoose.Schema.Types.Mixed,
isActive: { type: Boolean, default: true },
createdAt: { type: Date, default: Date.now },
updatedAt: { type: Date, default: Date.now }
});
module.exports = mongoose.model('Product', productSchema);
Şimdi GraphQL type definitions ve resolver’ları yazalım:
// schema/typeDefs.js
const { gql } = require('apollo-server');
const typeDefs = gql`
type Review {
userId: String
rating: Float
comment: String
createdAt: String
}
type Product {
id: ID!
sku: String!
name: String!
description: String
price: Float!
currency: String
category: String
tags: [String]
stock: Int
images: [String]
reviews: [Review]
isActive: Boolean
averageRating: Float
reviewCount: Int
createdAt: String
updatedAt: String
}
type ProductConnection {
products: [Product]
totalCount: Int
hasNextPage: Boolean
}
type CategoryStats {
category: String
productCount: Int
averagePrice: Float
totalStock: Int
}
input ProductFilter {
category: String
minPrice: Float
maxPrice: Float
inStock: Boolean
tags: [String]
isActive: Boolean
}
input ProductInput {
sku: String!
name: String!
description: String
price: Float!
currency: String
category: String
tags: [String]
stock: Int
}
input ReviewInput {
userId: String!
rating: Float!
comment: String
}
type Query {
product(id: ID, sku: String): Product
products(
filter: ProductFilter
limit: Int
offset: Int
sortBy: String
sortOrder: String
): ProductConnection
categoryStats: [CategoryStats]
searchProducts(query: String!, limit: Int): [Product]
}
type Mutation {
createProduct(input: ProductInput!): Product
updateProduct(id: ID!, input: ProductInput!): Product
deleteProduct(id: ID!): Boolean
addReview(productId: ID!, review: ReviewInput!): Product
updateStock(sku: String!, quantity: Int!): Product
}
`;
module.exports = typeDefs;
Resolver’lar burada kritik. Özellikle aggregation pipeline kullanan categoryStats ve searchProducts resolver’larına dikkat edin:
// schema/resolvers.js
const Product = require('../models/Product');
const resolvers = {
Query: {
product: async (_, { id, sku }) => {
try {
if (id) return await Product.findById(id);
if (sku) return await Product.findOne({ sku });
throw new Error('id veya sku parametresi gerekli');
} catch (err) {
throw new Error(`Ürün bulunamadı: ${err.message}`);
}
},
products: async (_, { filter = {}, limit = 20, offset = 0, sortBy = 'createdAt', sortOrder = 'desc' }) => {
const query = {};
if (filter.category) query.category = filter.category;
if (filter.minPrice || filter.maxPrice) {
query.price = {};
if (filter.minPrice) query.price.$gte = filter.minPrice;
if (filter.maxPrice) query.price.$lte = filter.maxPrice;
}
if (filter.inStock === true) query.stock = { $gt: 0 };
if (filter.tags && filter.tags.length > 0) query.tags = { $in: filter.tags };
if (filter.isActive !== undefined) query.isActive = filter.isActive;
const sortOptions = {};
sortOptions[sortBy] = sortOrder === 'asc' ? 1 : -1;
const [products, totalCount] = await Promise.all([
Product.find(query).sort(sortOptions).skip(offset).limit(limit),
Product.countDocuments(query)
]);
return {
products,
totalCount,
hasNextPage: offset + limit < totalCount
};
},
categoryStats: async () => {
return await Product.aggregate([
{ $match: { isActive: true } },
{
$group: {
_id: '$category',
productCount: { $sum: 1 },
averagePrice: { $avg: '$price' },
totalStock: { $sum: '$stock' }
}
},
{
$project: {
category: '$_id',
productCount: 1,
averagePrice: { $round: ['$averagePrice', 2] },
totalStock: 1,
_id: 0
}
},
{ $sort: { productCount: -1 } }
]);
},
searchProducts: async (_, { query, limit = 10 }) => {
return await Product.find(
{ $text: { $search: query }, isActive: true },
{ score: { $meta: 'textScore' } }
)
.sort({ score: { $meta: 'textScore' } })
.limit(limit);
}
},
Mutation: {
createProduct: async (_, { input }) => {
const product = new Product(input);
return await product.save();
},
updateProduct: async (_, { id, input }) => {
return await Product.findByIdAndUpdate(
id,
{ ...input, updatedAt: new Date() },
{ new: true, runValidators: true }
);
},
deleteProduct: async (_, { id }) => {
const result = await Product.findByIdAndDelete(id);
return !!result;
},
addReview: async (_, { productId, review }) => {
return await Product.findByIdAndUpdate(
productId,
{ $push: { reviews: review }, updatedAt: new Date() },
{ new: true }
);
},
updateStock: async (_, { sku, quantity }) => {
return await Product.findOneAndUpdate(
{ sku },
{ $inc: { stock: quantity }, updatedAt: new Date() },
{ new: true }
);
}
},
Product: {
id: (product) => product._id.toString(),
averageRating: (product) => {
if (!product.reviews || product.reviews.length === 0) return null;
const sum = product.reviews.reduce((acc, r) => acc + r.rating, 0);
return Math.round((sum / product.reviews.length) * 10) / 10;
},
reviewCount: (product) => product.reviews ? product.reviews.length : 0,
createdAt: (product) => product.createdAt ? product.createdAt.toISOString() : null,
updatedAt: (product) => product.updatedAt ? product.updatedAt.toISOString() : null
}
};
module.exports = resolvers;
Ana server dosyası:
// index.js
const { ApolloServer } = require('apollo-server');
const mongoose = require('mongoose');
require('dotenv').config();
const typeDefs = require('./schema/typeDefs');
const resolvers = require('./schema/resolvers');
const startServer = async () => {
await mongoose.connect(process.env.MONGODB_URI, {
useNewUrlParser: true,
useUnifiedTopology: true
});
console.log('MongoDB bağlantısı kuruldu');
// Text index oluştur (searchProducts için)
const Product = require('./models/Product');
await Product.collection.createIndex(
{ name: 'text', description: 'text', tags: 'text' },
{ name: 'product_text_index' }
);
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: true,
context: ({ req }) => ({
headers: req.headers
})
});
const { url } = await server.listen({ port: process.env.PORT || 4000, host: '0.0.0.0' });
console.log(`MongoDB GraphQL Server hazır: ${url}`);
};
startServer().catch(console.error);
Hasura’ya Remote Schema Ekleme
Server ayaktayken Hasura Console üzerinden veya API ile remote schema ekleyebilirsiniz. API yolunu tercih ediyorum çünkü CI/CD pipeline’ına daha kolay entegre edilebiliyor.
# Hasura Metadata API ile Remote Schema ekle
curl -X POST
-H "Content-Type: application/json"
-H "X-Hasura-Admin-Secret: myadminsecretkey"
-d '{
"type": "add_remote_schema",
"args": {
"name": "mongodb_products",
"definition": {
"url": "http://mongo-graphql-server:4000/",
"headers": [],
"forward_client_headers": false,
"timeout_seconds": 60,
"customization": {
"root_fields_namespace": "mongo",
"type_names": {
"prefix": "Mongo_"
}
}
},
"comment": "MongoDB ürün kataloğu servisi"
}
}'
http://localhost:8080/v1/metadata
root_fields_namespace ve type_names.prefix parametreleri önemli. PostgreSQL tarafındaki tiplerinizle çakışma yaşamamak için bu prefix’leri mutlaka tanımlayın. Yoksa production’da “Product tipi zaten var” hatasıyla saatler harcarsınız.
Remote Relationships Kurulumu
İşte Remote Schema’yı gerçekten güçlü kılan kısım burası. PostgreSQL’deki orders tablosundaki product_sku alanını MongoDB’deki Product tipine bağlayabiliriz.
# PostgreSQL orders tablosu ile MongoDB Product arasında ilişki kur
curl -X POST
-H "Content-Type: application/json"
-H "X-Hasura-Admin-Secret: myadminsecretkey"
-d '{
"type": "create_remote_relationship",
"args": {
"name": "product_details",
"source": "default",
"table": {
"schema": "public",
"name": "orders"
},
"definition": {
"to_remote_schema": {
"remote_schema": "mongodb_products",
"lhs_fields": ["product_sku"],
"remote_field": {
"mongo": {
"field": "product",
"arguments": {
"sku": "$product_sku"
}
}
}
}
}
}
}'
http://localhost:8080/v1/metadata
Bu kurulumdan sonra şöyle bir query yazabilirsiniz:
# PostgreSQL ve MongoDB verilerini tek sorguda getir
query GetOrdersWithProductDetails {
orders(where: { user_id: { _eq: "usr_123" } }) {
id
created_at
total_amount
status
product_sku
product_details {
name
price
currency
stock
images
averageRating
reviewCount
category
}
}
}
Bu query Hasura tarafından iki ayrı kaynağa yönlendiriliyor: PostgreSQL’den order bilgileri geliyor, sonra her order için product_sku değerini kullanarak MongoDB’deki server’a istek atılıyor. Kullanıcı açısından tek bir seamless GraphQL query.
Permissions ve Header Forwarding
Production ortamında JWT authentication kullanıyorsanız, Hasura’dan gelen header’ları MongoDB GraphQL server’ınıza forward edebilirsiniz. Server tarafında bu header’ları okuyup yetkilendirme yapabilirsiniz.
# Remote schema'yı header forwarding ile güncelle
curl -X POST
-H "Content-Type: application/json"
-H "X-Hasura-Admin-Secret: myadminsecretkey"
-d '{
"type": "update_remote_schema",
"args": {
"name": "mongodb_products",
"definition": {
"url": "http://mongo-graphql-server:4000/",
"headers": [
{
"name": "X-Service-Secret",
"value_from_env": "MONGO_SERVICE_SECRET"
}
],
"forward_client_headers": true,
"timeout_seconds": 60
}
}
}'
http://localhost:8080/v1/metadata
Server tarafında context’te header kontrolü:
// Server tarafında yetkilendirme middleware örneği
const server = new ApolloServer({
typeDefs,
resolvers,
context: ({ req }) => {
// Hasura'nın gönderdiği servis secret'ı kontrol et
const serviceSecret = req.headers['x-service-secret'];
if (serviceSecret !== process.env.EXPECTED_SERVICE_SECRET) {
throw new Error('Yetkisiz erişim');
}
// Hasura'nın forward ettiği user bilgilerini al
const hasuraRole = req.headers['x-hasura-role'];
const hasuraUserId = req.headers['x-hasura-user-id'];
return {
userRole: hasuraRole,
userId: hasuraUserId
};
}
});
Sık Karşılaşılan Sorunlar
Introspection hatası: Hasura remote schema eklerken “cannot introspect” hatası alırsanız, server’ın 0.0.0.0 üzerinde dinlediğinden ve Docker network erişiminin açık olduğundan emin olun. localhost:4000 değil, Docker service adını (mongo-graphql-server:4000) kullanın.
Type çakışmaları: Özellikle ID, String gibi scalar tipler dışında Product veya Review gibi custom tipler PostgreSQL şemanızda da varsa prefix olmadan içe aktarmak ciddi sorun yaratır. Her remote schema için farklı namespace ve prefix kullanın.
N+1 problemi: Remote relationships kullanırken order listesinde 100 sipariş varsa, 100 ayrı MongoDB isteği gidebilir. Bunu çözmek için DataLoader entegrasyonu yapın ya da query’lerinizi yeniden modelleyin.
Timeout ayarları: MongoDB aggregation pipeline’ları bazen uzun sürebilir. timeout_seconds değerini iş gereksinimlerinize göre ayarlayın, varsayılan 60 saniye genellikle yetmez.
Sonuç
Remote Schema yaklaşımı ilk bakışta “ekstra katman” gibi görünebilir, haklısınız. Ama bu katmanın size verdiği kontrol, özellikle karmaşık MongoDB veri modellerinde paha biçilemez. Aggregation pipeline’larınızı tam istediğiniz gibi GraphQL’e döküyorsunuz, Hasura’nın güçlü permission engine’i ve relationship özelliklerini üstüne ekliyorsunuz, ve client tarafına tek bir tutarlı API sunuyorsunuz.
Production’a taşımadan önce şunları unutmayın: connection pooling için Mongoose’un pool ayarlarını yapılandırın, MongoDB text indexlerinizi önceden oluşturun, ve remote schema timeout değerlerini gerçek load testleriyle belirleyin. Monitoring tarafında Apollo Server metrics’ini Hasura’nın kendi metrics’iyle birleştirirseniz request flow’unu baştan sona takip edebilirsiniz.
Bu mimari doğru kurulduğunda, legacy MongoDB verilerini modern bir GraphQL API’ye dönüştürmenin en temiz yollarından biri. Özellikle yeni başlayan ekiplere “hepsini yeniden yaz” yerine bu köprü yaklaşımını öneririm.
