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:
- Şifreyi hash’le
- Kullanıcıyı veritabanına kaydet
- Hoşgeldin e-postası gönder
- 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.
