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.

Bir yanıt yazın

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