Custom Actions

Extend your GraphQL API with custom business logic using Hasura Actions, connecting to serverless functions for complex operations beyond basic CRUD.

Overview

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.

Action Types

Synchronous Actions

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
  }
}

Asynchronous Actions

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
  }
}

Setting Up Actions

Enable Function Services

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

Create Action in Hasura Console

Access Hasura console at http://localhost:8080 and:

  1. Go to "Actions" tab
  2. Click "Create"
  3. Define your action
  4. Set handler URL
  5. Configure permissions

Action Definition Examples

User Registration Action

# 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!
}

Payment Processing Action

# 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
}

Function Implementation

NestJS Action Handler

// 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 }],
      };
    }
  }
}

Python Action Handler

# 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)

Action Configuration

Handler URL Configuration

# 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 and Authentication

# 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']}}"
}

Error Handling

Structured Error Responses

// 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"
}

HTTP Status Codes

# 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'
    });
  }
}

Advanced Action Patterns

Bulk Operations

# 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 Actions

# 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!
}

External API Integration

// 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
    };
  }
}

Testing Actions

Local Testing

# 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"
      }
    }
  }'

Unit Testing

// 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'
    });
  });
});

Performance and Optimization

Caching

// 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));
  }
}

Rate Limiting

// 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;
    }
  }
}

Best Practices

  • Validate input thoroughly: Never trust client input
  • Handle errors gracefully: Return structured error responses
  • Use appropriate HTTP status codes: Follow REST conventions
  • Implement idempotency: For operations that should not be repeated
  • Add logging and monitoring: Track action performance and errors
  • Cache when appropriate: Reduce database load for repeated queries
  • Implement rate limiting: Prevent abuse and DoS attacks
  • Test thoroughly: Unit test action handlers and edge cases
  • Document your actions: Keep action schemas and behavior documented
  • Version your actions: Plan for breaking changes

Next Steps