ZodulaZodula
Core

Actions

Actions

Actions

Actions in Zodula are server-side functions that create API endpoints. They handle HTTP requests, process data, and return responses with full type safety and validation.

Overview

Actions are the primary way to create API endpoints in Zodula. They automatically generate REST API endpoints with OpenAPI documentation and provide:

  • Type Safety: Full TypeScript support with request/response validation
  • Automatic API Generation: Creates REST endpoints from your functions
  • OpenAPI Documentation: Auto-generates API documentation
  • Request/Response Validation: Built-in validation using Zod schemas
  • Session Management: Access to user sessions and authentication

Basic Structure

import { z } from "zodula";

export default $action(async ctx => {
    // Your action logic here
    return ctx.json({
        message: "Success"
    });
}, {
    // Request/response schemas
    body: z.object({
        // Request body validation
    }),
    response: {
        200: z.object({
            // Response validation
        })
    }
});

File Structure and Action Paths

Actions use the file path to determine the API endpoint. The pattern is:

File Path: apps/zodula/actions/core/print.ts
Action Path: zodula.core.print

Directory Structure

apps/zodula/
├── actions/
│   ├── core/
│   │   ├── print.ts          # zodula.core.print
│   │   ├── login.ts          # zodula.core.login
│   │   └── logout.ts         # zodula.core.logout
│   ├── users/
│   │   ├── create.ts         # zodula.users.create
│   │   ├── update.ts         # zodula.users.update
│   │   └── delete.ts         # zodula.users.delete
│   └── products/
│       ├── list.ts           # zodula.products.list
│       └── detail.ts         # zodula.products.detail

Action Context

The ctx parameter provides access to:

  • ctx.body - Request body data
  • ctx.query - Query parameters
  • ctx.params - Route parameters
  • ctx.set.cookie() - Set cookies
  • ctx.json() - Return JSON response
  • ctx.text() - Return text response
  • ctx.status() - Set HTTP status code

Authentication Actions

Here's a complete example of authentication actions:

Login Action (apps/zodula/actions/auth/login.ts)

import { z } from "zodula";

export default $action(async ctx => {
    const { email, password } = ctx.body;
    
    if (!email || !password) {
        throw new Error("Email and password are required");
    }
    
    // Find user by email
    const { docs: users } = await $zodula.doctype("zodula__User")
        .select()
        .where("email", "=", email)
        .where("is_active", "=", 1)
        .unsafe(true)
        .bypass(true);
    
    if (!users[0]) {
        throw new Error("User not found");
    }
    
    const user = users[0];
    
    // Verify password
    const isPasswordValid = await Bun.password.verify(password, user.password);
    if (!isPasswordValid) {
        throw new Error("Invalid password");
    }
    
    // Create or update session
    const { docs: [existingSession] } = await $zodula.doctype("zodula__Session")
        .select()
        .where("user", "=", user.id)
        .sort("expires_at", "desc")
        .bypass(true);
    
    if (!existingSession) {
        // Create new session
        const createdSession = await $zodula.doctype("zodula__Session").insert({
            user: user.id,
            expires_at: new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString()
        }).bypass(true);
        
        // Set session cookies
        ctx.set.cookie("zodula_sid", createdSession.id, {
            path: "/",
            maxAge: 30 * 24 * 60 * 60 // 30 days
        });
        ctx.set.cookie("zodula_user_id", user.id, {
            path: "/",
            maxAge: 30 * 24 * 60 * 60
        });
        ctx.set.cookie("zodula_email", user.email, {
            path: "/",
            maxAge: 30 * 24 * 60 * 60
        });
        ctx.set.cookie("zodula_roles", user.roles?.map(role => role.role).join(",") || "", {
            path: "/",
            maxAge: 30 * 24 * 60 * 60
        });
        
        return ctx.json({
            session: createdSession,
            user: $zodula.utils.safe("zodula__User", user)
        });
    } else {
        // Update existing session
        const updatedSession = await $zodula.doctype("zodula__Session").update(existingSession.id, {
            expires_at: new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString()
        }).bypass(true);
        
        // Update cookies
        ctx.set.cookie("zodula_sid", updatedSession.id, {
            path: "/",
            maxAge: 30 * 24 * 60 * 60
        });
        ctx.set.cookie("zodula_user_id", user.id, {
            path: "/",
            maxAge: 30 * 24 * 60 * 60
        });
        ctx.set.cookie("zodula_email", user.email, {
            path: "/",
            maxAge: 30 * 24 * 60 * 60
        });
        ctx.set.cookie("zodula_roles", user.roles?.map(role => role.role).join(",") || "", {
            path: "/",
            maxAge: 30 * 24 * 60 * 60
        });
        
        return ctx.json({
            session: updatedSession,
            user: $zodula.utils.safe("zodula__User", user)
        });
    }
}, {
    body: z.object({
        email: z.string().email(),
        password: z.string()
    }),
    response: {
        200: z.object({
            session: $zodula.utils.zod("zodula__Session"),
            user: $zodula.utils.zod("zodula__User")
        })
    }
});

Get Current User (apps/zodula/actions/auth/me.ts)

import { z } from "zodula";

export default $action(async ctx => {
    const user = await $zodula.session.user();
    return ctx.json({
        user: $zodula.utils.safe("zodula__User", user)
    });
}, {
    response: {
        200: z.object({
            user: $zodula.utils.zod("zodula__User")
        })
    }
});

Logout Action (apps/zodula/actions/auth/logout.ts)

import { z } from "zodula";

export default $action(async ctx => {
    // Clear all session cookies
    ctx.set.cookie("zodula_sid", "", {
        path: "/",
        maxAge: 0
    });
    ctx.set.cookie("zodula_user_id", "", {
        path: "/",
        maxAge: 0
    });
    ctx.set.cookie("zodula_email", "", {
        path: "/",
        maxAge: 0
    });
    ctx.set.cookie("zodula_roles", "", {
        path: "/",
        maxAge: 0
    });
    
    return ctx.json({
        message: "Logged out successfully"
    });
}, {
    response: {
        200: z.object({
            message: z.string()
        })
    }
});

Get User Roles (apps/zodula/actions/auth/roles.ts)

import { z } from "zodula";

export default $action(async ctx => {
    const roles = await $zodula.session.roles();
    return ctx.json({
        roles
    });
}, {
    response: {
        200: z.object({
            roles: z.array(z.string())
        })
    }
});

Request/Response Schemas

Actions use Zod schemas for validation:

Request Body Validation

body: z.object({
    email: z.string().email(),
    password: z.string().min(6),
    age: z.number().optional()
})

Response Validation

response: {
    200: z.object({
        success: z.boolean(),
        data: z.object({
            id: z.string(),
            name: z.string()
        })
    }),
    400: z.object({
        error: z.string()
    })
}

API Endpoints

Actions automatically create REST API endpoints based on the file path:

  • POST /api/action/zodula.core.print - Execute the action (from apps/zodula/actions/core/print.ts)
  • POST /api/action/zodula.auth.login - Execute the action (from apps/zodula/actions/auth/login.ts)
  • POST /api/action/zodula.users.create - Execute the action (from apps/zodula/actions/users/create.ts)
  • GET /api/docs - OpenAPI documentation
  • GET /api/schema - JSON schema for validation

Path Mapping

The action path is determined by the file location:

  • File: apps/zodula/actions/core/print.ts → Path: zodula.core.print
  • File: apps/zodula/actions/auth/login.ts → Path: zodula.auth.login
  • File: apps/zodula/actions/users/create.ts → Path: zodula.users.create

Session Management

Access user sessions and authentication:

// Get current user
const user = await $zodula.session.user();

// Get user roles
const roles = await $zodula.session.roles();

// Check if user is authenticated
const isAuthenticated = await $zodula.session.isAuthenticated();

Database Operations

Actions can perform database operations:

Create User (apps/zodula/actions/users/create.ts)

import { z } from "zodula";

export default $action(async ctx => {
    const { name, email } = ctx.body;
    
    // Create new user
    const user = await $zodula.doctype("User").insert({
        name,
        email,
        created_at: new Date().toISOString()
    });
    
    return ctx.json({
        user: $zodula.utils.safe("User", user)
    });
}, {
    body: z.object({
        name: z.string(),
        email: z.string().email()
    }),
    response: {
        200: z.object({
            user: $zodula.utils.zod("User")
        })
    }
});

Error Handling

Actions can throw errors that are automatically handled:

Get User (apps/zodula/actions/users/get.ts)

import { z } from "zodula";

export default $action(async ctx => {
    const { id } = ctx.params;
    
    if (!id) {
        throw new Error("User ID is required");
    }
    
    const user = await $zodula.doctype("User").get(id);
    
    if (!user) {
        throw new Error("User not found");
    }
    
    return ctx.json({
        user: $zodula.utils.safe("User", user)
    });
});

File Structure

Actions are organized in the actions/ directory with each action in its own file:

apps/zodula/
├── actions/
│   ├── auth/
│   │   ├── login.ts         # zodula.auth.login
│   │   ├── logout.ts        # zodula.auth.logout
│   │   ├── me.ts            # zodula.auth.me
│   │   └── roles.ts         # zodula.auth.roles
│   ├── users/
│   │   ├── create.ts        # zodula.users.create
│   │   ├── get.ts           # zodula.users.get
│   │   ├── update.ts        # zodula.users.update
│   │   └── delete.ts        # zodula.users.delete
│   ├── products/
│   │   ├── list.ts          # zodula.products.list
│   │   ├── detail.ts        # zodula.products.detail
│   │   └── create.ts        # zodula.products.create
│   └── core/
│       ├── print.ts         # zodula.core.print
│       └── health.ts         # zodula.core.health

Best Practices

  1. Use TypeScript: Always use TypeScript for type safety
  2. Validate Input: Use Zod schemas for request validation
  3. Handle Errors: Throw meaningful error messages
  4. Use Sessions: Leverage session management for authentication
  5. Organize by Feature: Group related actions in the same file
  6. Document APIs: Use descriptive action names and comments
  7. Test Actions: Write tests for your actions
  8. Use Safe Utils: Use $zodula.utils.safe() for data sanitization

Example: Complete CRUD Actions

Create Product (apps/zodula/actions/products/create.ts)

import { z } from "zodula";

export default $action(async ctx => {
    const { name, price, description } = ctx.body;
    
    const product = await $zodula.doctype("Product").insert({
        name,
        price,
        description,
        created_at: new Date().toISOString()
    });
    
    return ctx.json({
        product: $zodula.utils.safe("Product", product)
    });
}, {
    body: z.object({
        name: z.string(),
        price: z.number(),
        description: z.string().optional()
    }),
    response: {
        200: z.object({
            product: $zodula.utils.zod("Product")
        })
    }
});

Get Product (apps/zodula/actions/products/get.ts)

import { z } from "zodula";

export default $action(async ctx => {
    const { id } = ctx.params;
    
    const product = await $zodula.doctype("Product").get(id);
    
    if (!product) {
        throw new Error("Product not found");
    }
    
    return ctx.json({
        product: $zodula.utils.safe("Product", product)
    });
}, {
    response: {
        200: z.object({
            product: $zodula.utils.zod("Product")
        })
    }
});

Update Product (apps/zodula/actions/products/update.ts)

import { z } from "zodula";

export default $action(async ctx => {
    const { id } = ctx.params;
    const { name, price, description } = ctx.body;
    
    const product = await $zodula.doctype("Product").update(id, {
        name,
        price,
        description,
        updated_at: new Date().toISOString()
    });
    
    return ctx.json({
        product: $zodula.utils.safe("Product", product)
    });
}, {
    body: z.object({
        name: z.string().optional(),
        price: z.number().optional(),
        description: z.string().optional()
    }),
    response: {
        200: z.object({
            product: $zodula.utils.zod("Product")
        })
    }
});

Delete Product (apps/zodula/actions/products/delete.ts)

import { z } from "zodula";

export default $action(async ctx => {
    const { id } = ctx.params;
    
    await $zodula.doctype("Product").delete(id);
    
    return ctx.json({
        message: "Product deleted successfully"
    });
}, {
    response: {
        200: z.object({
            message: z.string()
        })
    }
});

Testing Actions

You can test actions using HTTP requests with the new path structure:

# Login
curl -X POST http://localhost:3000/api/action/zodula.auth.login \
  -H "Content-Type: application/json" \
  -d '{"email": "user@example.com", "password": "password123"}'

# Get current user
curl -X POST http://localhost:3000/api/action/zodula.auth.me \
  -H "Content-Type: application/json"

# Create product
curl -X POST http://localhost:3000/api/action/zodula.products.create \
  -H "Content-Type: application/json" \
  -d '{"name": "Widget", "price": 29.99, "description": "A great widget"}'

# Get product
curl -X POST http://localhost:3000/api/action/zodula.products.get \
  -H "Content-Type: application/json" \
  -d '{"id": "product-id"}'

# Update product
curl -X POST http://localhost:3000/api/action/zodula.products.update \
  -H "Content-Type: application/json" \
  -d '{"id": "product-id", "name": "Updated Widget", "price": 39.99}'

# Delete product
curl -X POST http://localhost:3000/api/action/zodula.products.delete \
  -H "Content-Type: application/json" \
  -d '{"id": "product-id"}'

Actions are the foundation of your API in Zodula, providing type-safe, validated endpoints with automatic documentation generation.