Extend your GraphQL API with custom business logic using Hasura Actions, connecting to serverless functions for complex operations beyond basic CRUD.
Hasura Actions allow you to extend your auto-generated GraphQL API with custom business logic. Actions are custom GraphQL mutations and queries that call your serverless functions, enabling complex operations like payment processing, email sending, and external API integrations.
Real-time operations that return results immediately:
# Examples:
# - User registration with email verification
# - Payment processing
# - Data validation and transformation
# - External API calls
# - File processing
# GraphQL mutation example:
mutation RegisterUser($input: RegisterUserInput!) {
registerUser(input: $input) {
success
user {
id
email
name
}
message
}
}
Long-running operations that process in the background:
# Examples:
# - Bulk data processing
# - Report generation
# - Email campaigns
# - Image/video processing
# - Data exports
# Returns immediately with job ID:
mutation GenerateReport($input: ReportInput!) {
generateReport(input: $input) {
job_id
status
estimated_duration
}
}
First, enable the function services you want to use:
# In .env.local:
NESTJS_ENABLED=true
NESTJS_PORT=3001
# Or enable other function runtimes:
PYTHON_ENABLED=true
GOLANG_ENABLED=true
# Rebuild with function services
nself build && nself up
Access Hasura console at http://localhost:8080
and:
# Action Definition:
type Mutation {
registerUser(input: RegisterUserInput!): RegisterUserOutput
}
# Input Type:
input RegisterUserInput {
email: String!
password: String!
name: String!
company: String
}
# Output Type:
type RegisterUserOutput {
success: Boolean!
user: UserInfo
message: String!
errors: [ValidationError!]
}
type UserInfo {
id: UUID!
email: String!
name: String!
email_verified: Boolean!
}
type ValidationError {
field: String!
message: String!
}
# Action Definition:
type Mutation {
processPayment(input: PaymentInput!): PaymentOutput
}
# Input Type:
input PaymentInput {
amount: Int! # Amount in cents
currency: String!
payment_method_id: String!
customer_id: UUID!
description: String
}
# Output Type:
type PaymentOutput {
success: Boolean!
payment_intent_id: String
status: PaymentStatus!
message: String!
receipt_url: String
}
enum PaymentStatus {
SUCCEEDED
FAILED
REQUIRES_ACTION
PROCESSING
}
// functions/nestjs/src/actions/user.controller.ts
import { Controller, Post, Body, Headers } from '@nestjs/common';
import { UserService } from './user.service';
import { EmailService } from './email.service';
interface RegisterUserInput {
input: {
email: string;
password: string;
name: string;
company?: string;
};
}
@Controller('actions')
export class UserController {
constructor(
private userService: UserService,
private emailService: EmailService,
) {}
@Post('register-user')
async registerUser(
@Body() { input }: RegisterUserInput,
@Headers('x-hasura-user-id') userId?: string,
) {
try {
// Validate input
const validation = await this.userService.validateRegistration(input);
if (!validation.valid) {
return {
success: false,
message: 'Validation failed',
errors: validation.errors,
};
}
// Check if email already exists
const existingUser = await this.userService.findByEmail(input.email);
if (existingUser) {
return {
success: false,
message: 'Email already registered',
errors: [{ field: 'email', message: 'Email is already in use' }],
};
}
// Hash password
const hashedPassword = await this.userService.hashPassword(input.password);
// Create user in database
const user = await this.userService.create({
email: input.email,
password_hash: hashedPassword,
name: input.name,
company: input.company,
email_verified: false,
});
// Send verification email
await this.emailService.sendVerificationEmail(user.email, user.id);
return {
success: true,
user: {
id: user.id,
email: user.email,
name: user.name,
email_verified: user.email_verified,
},
message: 'User registered successfully. Please check your email for verification.',
};
} catch (error) {
console.error('Registration error:', error);
return {
success: false,
message: 'Registration failed. Please try again.',
errors: [{ field: 'general', message: error.message }],
};
}
}
}
# functions/python/actions/payment.py
from flask import Flask, request, jsonify
import stripe
import os
from typing import Dict, Any
app = Flask(__name__)
stripe.api_key = os.getenv('STRIPE_SECRET_KEY')
@app.route('/actions/process-payment', methods=['POST'])
def process_payment():
try:
data = request.get_json()
input_data = data['input']
# Extract payment details
amount = input_data['amount']
currency = input_data['currency']
payment_method_id = input_data['payment_method_id']
customer_id = input_data['customer_id']
description = input_data.get('description', '')
# Create payment intent with Stripe
payment_intent = stripe.PaymentIntent.create(
amount=amount,
currency=currency,
payment_method=payment_method_id,
customer=customer_id,
description=description,
confirm=True,
return_url='https://yourdomain.com/payment-complete',
)
# Handle payment result
if payment_intent.status == 'succeeded':
# Log successful payment
log_payment_success(customer_id, payment_intent.id, amount)
return jsonify({
'success': True,
'payment_intent_id': payment_intent.id,
'status': 'SUCCEEDED',
'message': 'Payment processed successfully',
'receipt_url': payment_intent.charges.data[0].receipt_url
})
elif payment_intent.status == 'requires_action':
return jsonify({
'success': False,
'payment_intent_id': payment_intent.id,
'status': 'REQUIRES_ACTION',
'message': 'Additional authentication required',
'client_secret': payment_intent.client_secret
})
else:
return jsonify({
'success': False,
'payment_intent_id': payment_intent.id,
'status': 'FAILED',
'message': f'Payment failed: {payment_intent.status}'
})
except stripe.error.CardError as e:
return jsonify({
'success': False,
'status': 'FAILED',
'message': f'Card error: {str(e)}'
}), 400
except Exception as e:
return jsonify({
'success': False,
'status': 'FAILED',
'message': 'Payment processing failed'
}), 500
def log_payment_success(customer_id: str, payment_intent_id: str, amount: int):
# Log to your database or analytics service
pass
if __name__ == '__main__':
app.run(host='0.0.0.0', port=3002)
# Handler URLs for different services:
# NestJS: http://nestjs:3001/actions/action-name
# Python: http://python:3002/actions/action-name
# Go: http://golang:3003/actions/action-name
# Development URLs (when testing locally):
# http://localhost:3001/actions/action-name
# http://localhost:3002/actions/action-name
# http://localhost:3003/actions/action-name
# Headers passed to action handlers:
# Content-Type: application/json
# X-Hasura-Admin-Secret: [admin-secret]
# X-Hasura-User-Id: [user-id] # If user is authenticated
# X-Hasura-Role: [user-role] # User's role
# X-Hasura-Session-Variables: [all-session-variables]
# Custom headers can be added:
{
"X-API-Key": "{{API_KEY}}",
"X-Request-ID": "{{REQUEST_ID}}",
"Authorization": "Bearer {{SESSION_VARIABLES['x-hasura-auth-token']}}"
}
// Action error response format
{
"success": false,
"message": "Validation failed",
"errors": [
{
"field": "email",
"message": "Email is required",
"code": "REQUIRED_FIELD"
},
{
"field": "password",
"message": "Password must be at least 8 characters",
"code": "INVALID_LENGTH"
}
],
"error_code": "VALIDATION_ERROR"
}
# Use appropriate HTTP status codes:
# 200: Success (even for business logic failures)
# 400: Bad request (invalid input)
# 401: Unauthorized (authentication required)
# 403: Forbidden (insufficient permissions)
# 500: Internal server error (unexpected errors)
// NestJS example:
@Post('register-user')
async registerUser(@Body() input: any, @Res() res: Response) {
try {
const result = await this.processRegistration(input);
if (result.success) {
return res.status(200).json(result);
} else {
// Business logic error - still return 200
return res.status(200).json(result);
}
} catch (error) {
// Unexpected error - return 500
return res.status(500).json({
success: false,
message: 'Internal server error'
});
}
}
# Bulk user creation action
type Mutation {
bulkCreateUsers(input: BulkCreateUsersInput!): BulkCreateUsersOutput
}
input BulkCreateUsersInput {
users: [CreateUserInput!]!
send_invitations: Boolean = false
}
type BulkCreateUsersOutput {
success: Boolean!
created_count: Int!
failed_count: Int!
results: [UserCreationResult!]!
}
type UserCreationResult {
email: String!
success: Boolean!
user: UserInfo
error: String
}
# File upload with processing
type Mutation {
uploadAndProcessFile(input: FileUploadInput!): FileUploadOutput
}
input FileUploadInput {
file_name: String!
file_type: String!
file_size: Int!
file_data: String! # Base64 encoded
processing_options: ProcessingOptions
}
type FileUploadOutput {
success: Boolean!
file_id: UUID
file_url: String
processing_job_id: UUID
message: String!
}
// Integrate with external services
@Post('sync-with-crm')
async syncWithCRM(
@Body() input: any,
@Headers('x-hasura-user-id') userId: string
) {
try {
// Get user's CRM integration settings
const integration = await this.integrationService.getCRMSettings(userId);
// Sync data with external CRM
const crmResult = await this.crmService.syncContacts({
apiKey: integration.api_key,
endpoint: integration.endpoint,
contacts: input.contacts
});
// Update local database with sync results
await this.contactService.updateSyncStatus(crmResult);
return {
success: true,
synced_count: crmResult.synced_count,
failed_count: crmResult.failed_count,
message: 'CRM sync completed successfully'
};
} catch (error) {
return {
success: false,
message: 'CRM sync failed',
error: error.message
};
}
}
# Test action directly (bypass Hasura)
curl -X POST http://localhost:3001/actions/register-user \
-H "Content-Type: application/json" \
-H "X-Hasura-Admin-Secret: your-admin-secret" \
-d '{
"input": {
"email": "test@example.com",
"password": "password123",
"name": "Test User"
}
}'
# Test through GraphQL API
curl -X POST http://localhost:8080/v1/graphql \
-H "Content-Type: application/json" \
-H "X-Hasura-Admin-Secret: your-admin-secret" \
-d '{
"query": "mutation RegisterUser($input: RegisterUserInput!) { registerUser(input: $input) { success message user { id email name } } }",
"variables": {
"input": {
"email": "test@example.com",
"password": "password123",
"name": "Test User"
}
}
}'
// NestJS action testing
import { Test } from '@nestjs/testing';
import { UserController } from './user.controller';
describe('UserController', () => {
let controller: UserController;
beforeEach(async () => {
const module = await Test.createTestingModule({
controllers: [UserController],
providers: [/* mock services */],
}).compile();
controller = module.get<UserController>(UserController);
});
it('should register user successfully', async () => {
const input = {
input: {
email: 'test@example.com',
password: 'password123',
name: 'Test User'
}
};
const result = await controller.registerUser(input);
expect(result.success).toBe(true);
expect(result.user.email).toBe(input.input.email);
expect(result.message).toContain('registered successfully');
});
it('should handle duplicate email', async () => {
// Mock existing user
jest.spyOn(userService, 'findByEmail').mockResolvedValue(existingUser);
const result = await controller.registerUser(input);
expect(result.success).toBe(false);
expect(result.errors).toContain({
field: 'email',
message: 'Email is already in use'
});
});
});
// Cache frequently used data
import { Injectable } from '@nestjs/common';
import Redis from 'ioredis';
@Injectable()
export class CacheService {
private redis = new Redis(process.env.REDIS_URL);
async getCachedUser(userId: string) {
const cached = await this.redis.get(`user:${userId}`);
return cached ? JSON.parse(cached) : null;
}
async setCachedUser(userId: string, user: any, ttl = 3600) {
await this.redis.setex(`user:${userId}`, ttl, JSON.stringify(user));
}
}
// Implement rate limiting for actions
import { Injectable } from '@nestjs/common';
import { RateLimiterRedis } from 'rate-limiter-flexible';
@Injectable()
export class RateLimitService {
private limiter = new RateLimiterRedis({
storeClient: redisClient,
keyPrefix: 'action_limit',
points: 5, // Number of requests
duration: 60, // Per 60 seconds
});
async checkLimit(userId: string): Promise<boolean> {
try {
await this.limiter.consume(userId);
return true;
} catch {
return false;
}
}
}