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.detailAction Context
The ctx parameter provides access to:
ctx.body- Request body datactx.query- Query parametersctx.params- Route parametersctx.set.cookie()- Set cookiesctx.json()- Return JSON responsectx.text()- Return text responsectx.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 (fromapps/zodula/actions/core/print.ts) - POST
/api/action/zodula.auth.login- Execute the action (fromapps/zodula/actions/auth/login.ts) - POST
/api/action/zodula.users.create- Execute the action (fromapps/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.healthBest Practices
- Use TypeScript: Always use TypeScript for type safety
- Validate Input: Use Zod schemas for request validation
- Handle Errors: Throw meaningful error messages
- Use Sessions: Leverage session management for authentication
- Organize by Feature: Group related actions in the same file
- Document APIs: Use descriptive action names and comments
- Test Actions: Write tests for your actions
- 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.
Zodula