Hasura Actions ile Özel İş Mantığı Ekleme

Hasura’nın otomatik GraphQL API üretimi gerçekten büyülü bir şey. Veritabanı şemanı tanımlıyorsun, Hasura geri kalanını yapıyor. Ama gerçek dünya uygulamalarında her zaman bu kadar basit olmuyor. Kullanıcı kaydı sırasında e-posta doğrulama göndermek, ödeme işlemi başlatmak, üçüncü parti API’larla konuşmak… Bunların hiçbirini saf SQL ile çözemezsin. İşte tam bu noktada Hasura Actions devreye giriyor.

Hasura Actions Nedir ve Neden Kullanırsın

Hasura Actions, mevcut GraphQL şemanı özel mutation veya query’lerle genişletmeni sağlayan bir mekanizma. Temelde şunu diyorsun: “Bu GraphQL operasyonu geldiğinde, şu HTTP endpoint’imi çağır, sonucu da GraphQL response olarak döndür.”

Bu yaklaşımın güzelliği şu: Hasura’nın otomatik CRUD API’ının tüm avantajlarından yararlanmaya devam ediyorsun, ama üstüne özel iş mantığı da ekleyebiliyorsun. Servis mimarisi açısından da temiz bir çözüm. Action handler’larını istediğin dilde yazabilirsin, istediğin yerde host edebilirsin.

Peki Actions’ı ne zaman kullanmalısın?

  • Ödeme gateway entegrasyonları (Stripe, iyzico, PayTR)
  • E-posta/SMS gönderimi
  • Dosya upload ve işleme süreçleri
  • Üçüncü parti OAuth akışları
  • Karmaşık iş kuralları gerektiren operasyonlar
  • Birden fazla veritabanı işlemini koordine etmek gerektiğinde

Temel Kavramlar: Action Types

Actions iki tipte gelir:

Mutation Actions: Veri değiştiren işlemler için. Kullanıcı kaydı, sipariş oluşturma gibi.

Query Actions: Veri okuma işlemleri için. Ama dikkat, buradaki “query” kavramı biraz yanıltıcı. Aslında arka tarafta HTTP POST çağrısı yapılıyor. Harici bir API’dan veri çekip döndürmek istediğinde kullanırsın.

İlk Action’ı Oluşturmak: Kullanıcı Kayıt Senaryosu

Klasik bir senaryo ile başlayalım. Kullanıcı kaydı sırasında şunları yapmamız gerekiyor:

  1. Şifreyi hash’le
  2. Kullanıcıyı veritabanına kaydet
  3. Hoşgeldin e-postası gönder
  4. JWT token üret ve döndür

Hasura Console’a gidip Actions sekmesinden yeni action tanımlıyoruz:

type Mutation {
  registerUser(
    email: String!
    password: String!
    fullName: String!
  ): RegisterUserOutput
}

type RegisterUserOutput {
  userId: uuid!
  token: String!
  message: String!
}

Action tanımını kaydettiğinde Hasura sana otomatik olarak bir webhook URL’i soruyor. Bu URL, action tetiklendiğinde Hasura’nın POST isteği atacağı endpoint.

Action Handler Yazmak: Node.js Örneği

Handler tarafında Express.js kullanan basit bir uygulama yazalım:

const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { Pool } = require('pg');

const app = express();
app.use(express.json());

const pool = new Pool({
  connectionString: process.env.DATABASE_URL
});

app.post('/register-user', async (req, res) => {
  const { input, session_variables } = req.body;
  const { email, password, fullName } = input;

  try {
    // E-posta zaten kayıtlı mı?
    const existingUser = await pool.query(
      'SELECT id FROM users WHERE email = $1',
      [email]
    );

    if (existingUser.rows.length > 0) {
      return res.status(400).json({
        message: 'Bu e-posta adresi zaten kayıtlı',
        extensions: {
          code: 'EMAIL_ALREADY_EXISTS'
        }
      });
    }

    // Şifreyi hash'le
    const hashedPassword = await bcrypt.hash(password, 12);

    // Kullanıcıyı kaydet
    const result = await pool.query(
      `INSERT INTO users (email, password_hash, full_name, created_at)
       VALUES ($1, $2, $3, NOW())
       RETURNING id`,
      [email, hashedPassword, fullName]
    );

    const userId = result.rows[0].id;

    // JWT üret
    const token = jwt.sign(
      {
        userId,
        email,
        'https://hasura.io/jwt/claims': {
          'x-hasura-allowed-roles': ['user'],
          'x-hasura-default-role': 'user',
          'x-hasura-user-id': userId
        }
      },
      process.env.JWT_SECRET,
      { expiresIn: '7d' }
    );

    // Hoşgeldin e-postası gönder (async, kullanıcıyı bekletme)
    sendWelcomeEmail(email, fullName).catch(console.error);

    return res.json({
      userId,
      token,
      message: 'Kayıt başarılı! Hoşgeldin.'
    });

  } catch (error) {
    console.error('Register error:', error);
    return res.status(500).json({
      message: 'Sunucu hatası oluştu'
    });
  }
});

app.listen(3001, () => {
  console.log('Action handler 3001 portunda çalışıyor');
});

Hata Yönetimi: Hasura Actions’ta Error Handling

Hasura Actions’ta hata döndürme konvansiyonu önemli. Eğer yanlış format kullanırsan, client tarafında anlamsız hata mesajları görürsün.

// YANLIS yontem - bunu yapma
return res.status(400).json({ error: 'Bir şeyler ters gitti' });

// DOGRU yontem
return res.status(400).json({
  message: 'Kullanıcı bulunamadı',
  extensions: {
    code: 'USER_NOT_FOUND',
    path: '$'
  }
});

HTTP status koduna gelince, Hasura 200 dışındaki tüm status kodlarını hata olarak işler ve GraphQL error formatında client’a iletir. 400 veya 500 döndürdüğünde Hasura bunu errors array’ine ekler.

Async Action’lar: Uzun Süren İşlemler

Bazen bir işlemin tamamlanması zaman alır. Büyük veri export’u, video transcode, bulk e-posta gönderimi gibi. Bu durumlarda Async Actions kullanırsın.

Async Action tanımlarken ## işaretini kullanıyorsun:

type Mutation {
  exportUserData(
    userId: uuid!
    format: String!
  ): ExportActionOutput
}

type ExportActionOutput {
  actionId: uuid!
}

Hasura Console’da Action’ı tanımlarken “Async” seçeneğini işaretliyorsun. Bu durumda Hasura hemen bir actionId döndürüyor ve arka planda işlemi takip edebiliyorsun.

app.post('/export-user-data', async (req, res) => {
  const { input, action } = req.body;
  const { userId, format } = input;
  const actionId = action.id; // Hasura'nin verdigi action ID

  // Hemen 200 don, islemi arka planda baslat
  res.json({ actionId });

  // Arka planda isle
  processExport(userId, format, actionId)
    .then(() => updateActionStatus(actionId, 'completed'))
    .catch((err) => updateActionStatus(actionId, 'failed', err.message));
});

async function updateActionStatus(actionId, status, errorMsg = null) {
  const mutation = `
    mutation UpdateAction($actionId: uuid!, $status: String!) {
      update_actions_by_pk(
        pk_columns: { id: $actionId }
        _set: { status: $status }
      ) {
        id
      }
    }
  `;

  await fetch(process.env.HASURA_GRAPHQL_ENDPOINT, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-hasura-admin-secret': process.env.HASURA_ADMIN_SECRET
    },
    body: JSON.stringify({
      query: mutation,
      variables: { actionId, status }
    })
  });
}

Client tarafında action durumunu subscription ile takip edebilirsin:

subscription TrackExport($actionId: uuid!) {
  export_user_data(id: $actionId) {
    status
    output
    errors {
      message
    }
  }
}

Action Permissions: Kimin Ne Yapabileceğini Kontrol Etmek

Action’lar Hasura’nın permission sistemiyle entegre çalışır. Hangi rolün hangi action’ı çağırabileceğini tanımlayabilirsin.

# Hasura CLI ile action permission tanimlamak
hasura metadata apply

metadata/actions.yaml dosyasında:

- name: registerUser
  definition:
    kind: synchronous
    handler: '{{ACTION_BASE_URL}}/register-user'
  permissions:
  - role: anonymous
- name: adminBulkDelete
  definition:
    kind: synchronous
    handler: '{{ACTION_BASE_URL}}/bulk-delete'
  permissions:
  - role: admin
- name: updateMyProfile
  definition:
    kind: synchronous
    handler: '{{ACTION_BASE_URL}}/update-profile'
  permissions:
  - role: user

Handler tarafında session_variables’ı kullanarak kullanıcı kimliğini doğrulayabilirsin:

app.post('/update-profile', async (req, res) => {
  const { input, session_variables } = req.body;
  
  // Hasura'dan gelen kullanici bilgileri
  const currentUserId = session_variables['x-hasura-user-id'];
  const userRole = session_variables['x-hasura-role'];
  
  // Sadece kendi profilini guncelleyebilir
  if (input.userId !== currentUserId && userRole !== 'admin') {
    return res.status(403).json({
      message: 'Bu işlem için yetkiniz yok',
      extensions: { code: 'FORBIDDEN' }
    });
  }
  
  // Profil guncelleme islemi...
  const result = await updateProfile(input);
  return res.json(result);
});

Gerçek Dünya Senaryosu: Stripe Ödeme Entegrasyonu

E-ticaret uygulamaları için en sık ihtiyaç duyulan entegrasyonlardan biri ödeme sistemleri. Stripe ile nasıl çalıştığına bakalım:

type Mutation {
  createPaymentIntent(
    amount: Int!
    currency: String!
    orderId: uuid!
  ): PaymentIntentOutput
}

type PaymentIntentOutput {
  clientSecret: String!
  paymentIntentId: String!
}
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);

app.post('/create-payment-intent', async (req, res) => {
  const { input, session_variables } = req.body;
  const { amount, currency, orderId } = input;
  const userId = session_variables['x-hasura-user-id'];

  try {
    // Siparis var mi ve bu kullaniciya ait mi kontrol et
    const orderCheck = await fetch(process.env.HASURA_GRAPHQL_ENDPOINT, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'x-hasura-admin-secret': process.env.HASURA_ADMIN_SECRET
      },
      body: JSON.stringify({
        query: `
          query CheckOrder($orderId: uuid!, $userId: uuid!) {
            orders_by_pk(id: $orderId) {
              id
              user_id
              status
              total_amount
            }
          }
        `,
        variables: { orderId, userId }
      })
    });

    const orderData = await orderCheck.json();
    const order = orderData.data?.orders_by_pk;

    if (!order) {
      return res.status(404).json({
        message: 'Sipariş bulunamadı',
        extensions: { code: 'ORDER_NOT_FOUND' }
      });
    }

    if (order.user_id !== userId) {
      return res.status(403).json({
        message: 'Bu sipariş size ait değil',
        extensions: { code: 'FORBIDDEN' }
      });
    }

    // Stripe PaymentIntent olustur
    const paymentIntent = await stripe.paymentIntents.create({
      amount: amount,
      currency: currency,
      metadata: {
        orderId,
        userId
      }
    });

    // PaymentIntent ID'yi sipariste sakla
    await fetch(process.env.HASURA_GRAPHQL_ENDPOINT, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'x-hasura-admin-secret': process.env.HASURA_ADMIN_SECRET
      },
      body: JSON.stringify({
        query: `
          mutation UpdateOrderPayment($orderId: uuid!, $paymentIntentId: String!) {
            update_orders_by_pk(
              pk_columns: { id: $orderId }
              _set: { 
                payment_intent_id: $paymentIntentId,
                status: "payment_pending"
              }
            ) {
              id
            }
          }
        `,
        variables: { orderId, paymentIntentId: paymentIntent.id }
      })
    });

    return res.json({
      clientSecret: paymentIntent.client_secret,
      paymentIntentId: paymentIntent.id
    });

  } catch (error) {
    console.error('Stripe error:', error);
    return res.status(500).json({
      message: 'Ödeme başlatılamadı',
      extensions: { code: 'PAYMENT_ERROR' }
    });
  }
});

Action Handler’ı Docker ile Deploy Etmek

Production ortamına geçerken handler’ı nasıl deploy edeceğimize bakalım:

# Dockerfile
FROM node:18-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci --only=production

COPY . .

EXPOSE 3001

CMD ["node", "server.js"]
# docker-compose.yml ile Hasura ile birlikte calistirma
version: '3.8'

services:
  postgres:
    image: postgres:15
    environment:
      POSTGRES_PASSWORD: mysecretpassword
      POSTGRES_DB: myapp
    volumes:
      - pgdata:/var/lib/postgresql/data

  hasura:
    image: hasura/graphql-engine:v2.36.0
    depends_on:
      - postgres
    environment:
      HASURA_GRAPHQL_DATABASE_URL: postgres://postgres:mysecretpassword@postgres:5432/myapp
      HASURA_GRAPHQL_ADMIN_SECRET: myadminsecretkey
      HASURA_GRAPHQL_JWT_SECRET: '{"type":"HS256","key":"supersecretjwtkey"}'
      ACTION_BASE_URL: http://action-handler:3001
    ports:
      - "8080:8080"

  action-handler:
    build: ./action-handler
    environment:
      DATABASE_URL: postgres://postgres:mysecretpassword@postgres:5432/myapp
      HASURA_GRAPHQL_ENDPOINT: http://hasura:8080/v1/graphql
      HASURA_ADMIN_SECRET: myadminsecretkey
      JWT_SECRET: supersecretjwtkey
      STRIPE_SECRET_KEY: sk_test_xxxxx
    ports:
      - "3001:3001"

volumes:
  pgdata:

Action Handler Test Etmek

Action’ları test ederken dikkat etmen gereken nokta: Hasura, handler’a belirli bir format gönderiyor. Bunu bilirsen handler’ı Hasura olmadan da test edebilirsin:

# Action handler'i direkt test et
curl -X POST http://localhost:3001/register-user 
  -H "Content-Type: application/json" 
  -d '{
    "action": {
      "name": "registerUser"
    },
    "input": {
      "email": "[email protected]",
      "password": "sifre123",
      "fullName": "Ahmet Yilmaz"
    },
    "session_variables": {
      "x-hasura-role": "anonymous"
    }
  }'
# GraphQL uzerinden test
curl -X POST http://localhost:8080/v1/graphql 
  -H "Content-Type: application/json" 
  -d '{
    "query": "mutation Register($email: String!, $password: String!, $fullName: String!) { registerUser(email: $email, password: $password, fullName: $fullName) { userId token message } }",
    "variables": {
      "email": "[email protected]",
      "password": "sifre123",
      "fullName": "Ahmet Yilmaz"
    }
  }'

Performance ve Timeout Konuları

Action handler’lar için dikkat etmen gereken birkaç kritik nokta var:

Timeout: Hasura’nın default timeout değeri 60 saniye. Uzun süren işlemler için Async Actions kullan.

Retry Mekanizması: Hasura başarısız action çağrılarını retry etmez. Bunu kendi tarafında implement etmen gerekiyor.

Connection Pooling: Her action çağrısında yeni veritabanı bağlantısı açma. Connection pool kullan:

// PgBouncer veya dogrudan pg pool kullan
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 20,              // maksimum baglanti sayisi
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 2000
});

// Handler'larda pool.query kullan, client.connect degil
const result = await pool.query(/* ... */);

Caching: Sık çağrılan ve değişmeyen veriler için Redis cache eklemek, handler performansını ciddi artırır:

const redis = require('redis');
const client = redis.createClient({ url: process.env.REDIS_URL });

app.post('/get-product-catalog', async (req, res) => {
  const cacheKey = 'product:catalog';
  
  // Once cache'e bak
  const cached = await client.get(cacheKey);
  if (cached) {
    return res.json(JSON.parse(cached));
  }
  
  // Cache'de yoksa veritabanindan cek
  const products = await fetchProductsFromDB();
  
  // 5 dakika cache'e kaydet
  await client.setEx(cacheKey, 300, JSON.stringify(products));
  
  return res.json(products);
});

Hasura CLI ile Action Yönetimi

Production’da console yerine CLI kullanmak çok daha sağlıklı. Actions metadata olarak yönetilir:

# Mevcut metadata'yi export et
hasura metadata export

# Actions dosyasini kontrol et
cat metadata/actions.yaml

# Degisiklikleri uygulamak
hasura metadata apply --endpoint https://your-hasura-instance.com 
  --admin-secret your-admin-secret

# Actions'in saglikli olup olmadigini kontrol et
hasura metadata ic

CI/CD pipeline’ına eklerken:

# GitHub Actions veya GitLab CI'da kullan
hasura metadata apply 
  --endpoint $HASURA_ENDPOINT 
  --admin-secret $HASURA_ADMIN_SECRET 
  --disallow-inconsistent-metadata

Sonuç

Hasura Actions, Hasura’nın en güçlü özelliklerinden biri. Otomatik CRUD API’ın sunduğu hızla, özel iş mantığı yazmanın esnekliğini birleştiriyor. Doğru kullanıldığında çok temiz bir mimari ortaya çıkıyor: Hasura veritabanı operasyonlarını yönetiyor, sen sadece gerçekten özel mantık gerektiren kısımları yazıyorsun.

Dikkat etmen gereken birkaç pratik nokta var: Sync action’ları 60 saniyenin altında tutmaya özen göster, uzun işlemlerde async yaklaşımı tercih et. Session variables aracılığıyla yetkilendirmeyi doğru yap, handler’a güvenme ve her zaman Hasura’nın gönderdiği kullanıcı bilgisini doğrula. Error format konvansiyonuna uy ki client tarafı hataları anlamlı şekilde işleyebilsin.

Actions’ı Event Triggers ile kombinlediğinde daha da güçlü bir sistem elde ediyorsun. Mesela ödeme başarılı olduğunda bir trigger tetiklenebilir, o trigger bir action’ı çağırabilir, action da fatura oluşturup müşteriye gönderebilir. Bu zincir mantığı kurduğunda Hasura gerçekten eksiksiz bir backend platformuna dönüşüyor.

Geliştirme sürecinde action handler’larını Hasura’dan bağımsız test edebilmek büyük avantaj. curl ile doğrudan POST atabiliyorsun, unit test yazabiliyorsun. Bu da CI/CD süreçlerine güzelce entegre olduğu anlamına geliyor.

Bir yanıt yazın

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