Controllers

Learn how to organize business logic and request handling in SvelteKit applications with the Asterisms JS SDK Backend.

Overview

Controllers in SvelteKit are implemented as route handlers that process requests and return responses. The Asterisms JS SDK Backend provides patterns and utilities to organize your business logic effectively.

Basic Controller Pattern

Simple Controller

// src/routes/api/users/+server.ts
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';

export const GET: RequestHandler = async ({ locals }) => {
  const sdk = locals.sdk;
  if (!sdk) {
    return json({ error: 'SDK not available' }, { status: 503 });
  }

  try {
    const storage = sdk.storage();
    const users = await storage.list('users');

    return json({ users });
  } catch (error) {
    console.error('Failed to fetch users:', error);
    return json({ error: 'Failed to fetch users' }, { status: 500 });
  }
};

Controller Class Pattern

// src/lib/controllers/UserController.ts
import { json } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import type { AsterismsBackendSDK } from '@asterisms/sdk-backend';

export class UserController {
  constructor(private sdk: AsterismsBackendSDK) {}

  async getUsers(event: RequestEvent) {
    try {
      const storage = this.sdk.storage();
      const users = await storage.list('users');

      return json({ users });
    } catch (error) {
      console.error('Failed to fetch users:', error);
      return json({ error: 'Failed to fetch users' }, { status: 500 });
    }
  }

  async createUser(event: RequestEvent) {
    try {
      const userData = await event.request.json();
      const storage = this.sdk.storage();

      // Validate user data
      this.validateUserData(userData);

      const newUser = await storage.create('users', userData);
      return json({ user: newUser }, { status: 201 });
    } catch (error) {
      console.error('Failed to create user:', error);
      return json({ error: 'Failed to create user' }, { status: 500 });
    }
  }

  async updateUser(event: RequestEvent) {
    try {
      const { params } = event;
      const userData = await event.request.json();
      const storage = this.sdk.storage();

      const updatedUser = await storage.update('users', params.id, userData);
      return json({ user: updatedUser });
    } catch (error) {
      console.error('Failed to update user:', error);
      return json({ error: 'Failed to update user' }, { status: 500 });
    }
  }

  async deleteUser(event: RequestEvent) {
    try {
      const { params } = event;
      const storage = this.sdk.storage();

      await storage.delete('users', params.id);
      return json({ success: true });
    } catch (error) {
      console.error('Failed to delete user:', error);
      return json({ error: 'Failed to delete user' }, { status: 500 });
    }
  }

  private validateUserData(userData: any) {
    if (!userData.email || !userData.name) {
      throw new Error('Email and name are required');
    }

    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(userData.email)) {
      throw new Error('Invalid email format');
    }
  }
}

Using Controller in Routes

// src/routes/api/users/+server.ts
import { UserController } from '$lib/controllers/UserController';
import type { RequestHandler } from './$types';

export const GET: RequestHandler = async (event) => {
  const controller = new UserController(event.locals.sdk);
  return await controller.getUsers(event);
};

export const POST: RequestHandler = async (event) => {
  const controller = new UserController(event.locals.sdk);
  return await controller.createUser(event);
};

Advanced Controller Patterns

Base Controller Class

// src/lib/controllers/BaseController.ts
import { json } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import type { AsterismsBackendSDK } from '@asterisms/sdk-backend';

export abstract class BaseController {
  protected sdk: AsterismsBackendSDK;

  constructor(sdk: AsterismsBackendSDK) {
    this.sdk = sdk;
  }

  protected async requireAuth(event: RequestEvent) {
    if (!event.locals.user) {
      return json({ error: 'Unauthorized' }, { status: 401 });
    }
    return null;
  }

  protected async requirePermission(permission: string) {
    const auth = this.sdk.authorization();
    const hasPermission = await auth.hasPermission(permission);

    if (!hasPermission) {
      return json(
        {
          error: `Missing permission: ${permission}`
        },
        { status: 403 }
      );
    }

    return null;
  }

  protected handleError(error: any, context: string) {
    console.error(`${context}:`, error);

    if (error.name === 'ValidationError') {
      return json({ error: error.message }, { status: 400 });
    }

    if (error.name === 'NotFoundError') {
      return json({ error: 'Resource not found' }, { status: 404 });
    }

    return json({ error: 'Internal server error' }, { status: 500 });
  }

  protected async parseJSON(request: Request) {
    try {
      return await request.json();
    } catch (error) {
      throw new Error('Invalid JSON in request body');
    }
  }
}

Extended Controller

// src/lib/controllers/ProductController.ts
import { BaseController } from './BaseController';
import { json } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';

export class ProductController extends BaseController {
  async getProducts(event: RequestEvent) {
    try {
      const authCheck = await this.requireAuth(event);
      if (authCheck) return authCheck;

      const permissionCheck = await this.requirePermission('products.read');
      if (permissionCheck) return permissionCheck;

      const storage = this.sdk.storage();
      const products = await storage.list('products');

      return json({ products });
    } catch (error) {
      return this.handleError(error, 'ProductController.getProducts');
    }
  }

  async createProduct(event: RequestEvent) {
    try {
      const authCheck = await this.requireAuth(event);
      if (authCheck) return authCheck;

      const permissionCheck = await this.requirePermission('products.create');
      if (permissionCheck) return permissionCheck;

      const productData = await this.parseJSON(event.request);

      // Validate product data
      this.validateProductData(productData);

      const storage = this.sdk.storage();
      const newProduct = await storage.create('products', {
        ...productData,
        createdBy: event.locals.user.id,
        createdAt: new Date().toISOString()
      });

      return json({ product: newProduct }, { status: 201 });
    } catch (error) {
      return this.handleError(error, 'ProductController.createProduct');
    }
  }

  private validateProductData(productData: any) {
    if (!productData.name || !productData.price) {
      throw new Error('Name and price are required');
    }

    if (typeof productData.price !== 'number' || productData.price <= 0) {
      throw new Error('Price must be a positive number');
    }
  }
}

Service Layer Pattern

Service Classes

// src/lib/services/UserService.ts
import type { AsterismsBackendSDK } from '@asterisms/sdk-backend';

export interface User {
  id: string;
  email: string;
  name: string;
  role: 'admin' | 'user' | 'viewer';
  createdAt: string;
  updatedAt: string;
}

export class UserService {
  constructor(private sdk: AsterismsBackendSDK) {}

  async findAll(): Promise<User[]> {
    const storage = this.sdk.storage();
    return await storage.list('users');
  }

  async findById(id: string): Promise<User | null> {
    const storage = this.sdk.storage();
    return await storage.get('users', id);
  }

  async create(userData: Partial<User>): Promise<User> {
    const storage = this.sdk.storage();

    const user: User = {
      id: crypto.randomUUID(),
      email: userData.email!,
      name: userData.name!,
      role: userData.role || 'user',
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString()
    };

    return await storage.create('users', user);
  }

  async update(id: string, userData: Partial<User>): Promise<User> {
    const storage = this.sdk.storage();

    const updates = {
      ...userData,
      updatedAt: new Date().toISOString()
    };

    return await storage.update('users', id, updates);
  }

  async delete(id: string): Promise<void> {
    const storage = this.sdk.storage();
    await storage.delete('users', id);
  }

  async findByEmail(email: string): Promise<User | null> {
    const storage = this.sdk.storage();
    const users = await storage.list('users');
    return users.find((user) => user.email === email) || null;
  }
}

Controller Using Service

// src/lib/controllers/UserController.ts
import { BaseController } from './BaseController';
import { UserService } from '$lib/services/UserService';
import { json } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';

export class UserController extends BaseController {
  private userService: UserService;

  constructor(sdk: AsterismsBackendSDK) {
    super(sdk);
    this.userService = new UserService(sdk);
  }

  async getUsers(event: RequestEvent) {
    try {
      const authCheck = await this.requireAuth(event);
      if (authCheck) return authCheck;

      const permissionCheck = await this.requirePermission('users.read');
      if (permissionCheck) return permissionCheck;

      const users = await this.userService.findAll();
      return json({ users });
    } catch (error) {
      return this.handleError(error, 'UserController.getUsers');
    }
  }

  async getUser(event: RequestEvent) {
    try {
      const authCheck = await this.requireAuth(event);
      if (authCheck) return authCheck;

      const { params } = event;
      const user = await this.userService.findById(params.id);

      if (!user) {
        return json({ error: 'User not found' }, { status: 404 });
      }

      return json({ user });
    } catch (error) {
      return this.handleError(error, 'UserController.getUser');
    }
  }

  async createUser(event: RequestEvent) {
    try {
      const authCheck = await this.requireAuth(event);
      if (authCheck) return authCheck;

      const permissionCheck = await this.requirePermission('users.create');
      if (permissionCheck) return permissionCheck;

      const userData = await this.parseJSON(event.request);

      // Check if user already exists
      const existingUser = await this.userService.findByEmail(userData.email);
      if (existingUser) {
        return json({ error: 'User already exists' }, { status: 409 });
      }

      const newUser = await this.userService.create(userData);
      return json({ user: newUser }, { status: 201 });
    } catch (error) {
      return this.handleError(error, 'UserController.createUser');
    }
  }
}

Validation and DTOs

Data Transfer Objects

// src/lib/dto/UserDto.ts
import { z } from 'zod';

export const CreateUserDto = z.object({
  email: z.string().email(),
  name: z.string().min(1).max(100),
  role: z.enum(['admin', 'user', 'viewer']).default('user')
});

export const UpdateUserDto = z.object({
  name: z.string().min(1).max(100).optional(),
  role: z.enum(['admin', 'user', 'viewer']).optional()
});

export type CreateUserData = z.infer<typeof CreateUserDto>;
export type UpdateUserData = z.infer<typeof UpdateUserDto>;

Controller with Validation

// src/lib/controllers/UserController.ts
import { CreateUserDto, UpdateUserDto } from '$lib/dto/UserDto';
import { BaseController } from './BaseController';
import { json } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';

export class UserController extends BaseController {
  async createUser(event: RequestEvent) {
    try {
      const authCheck = await this.requireAuth(event);
      if (authCheck) return authCheck;

      const permissionCheck = await this.requirePermission('users.create');
      if (permissionCheck) return permissionCheck;

      const requestData = await this.parseJSON(event.request);

      // Validate request data
      const validationResult = CreateUserDto.safeParse(requestData);
      if (!validationResult.success) {
        return json(
          {
            error: 'Validation failed',
            details: validationResult.error.errors
          },
          { status: 400 }
        );
      }

      const userData = validationResult.data;
      const newUser = await this.userService.create(userData);

      return json({ user: newUser }, { status: 201 });
    } catch (error) {
      return this.handleError(error, 'UserController.createUser');
    }
  }

  async updateUser(event: RequestEvent) {
    try {
      const authCheck = await this.requireAuth(event);
      if (authCheck) return authCheck;

      const permissionCheck = await this.requirePermission('users.update');
      if (permissionCheck) return permissionCheck;

      const { params } = event;
      const requestData = await this.parseJSON(event.request);

      // Validate request data
      const validationResult = UpdateUserDto.safeParse(requestData);
      if (!validationResult.success) {
        return json(
          {
            error: 'Validation failed',
            details: validationResult.error.errors
          },
          { status: 400 }
        );
      }

      const userData = validationResult.data;
      const updatedUser = await this.userService.update(params.id, userData);

      return json({ user: updatedUser });
    } catch (error) {
      return this.handleError(error, 'UserController.updateUser');
    }
  }
}

File Upload Controllers

File Upload Service

// src/lib/services/FileUploadService.ts
import type { AsterismsBackendSDK } from '@asterisms/sdk-backend';

export interface UploadResult {
  filename: string;
  url: string;
  size: number;
  mimeType: string;
}

export class FileUploadService {
  constructor(private sdk: AsterismsBackendSDK) {}

  async uploadFile(file: File, path: string): Promise<UploadResult> {
    const storage = this.sdk.storage();

    // Convert File to ArrayBuffer
    const buffer = await file.arrayBuffer();

    // Generate unique filename
    const filename = `${Date.now()}-${file.name}`;
    const fullPath = `${path}/${filename}`;

    // Store file
    await storage.set(fullPath, buffer);

    return {
      filename,
      url: `/api/files/${encodeURIComponent(fullPath)}`,
      size: file.size,
      mimeType: file.type
    };
  }

  async getFile(path: string): Promise<ArrayBuffer | null> {
    const storage = this.sdk.storage();
    return await storage.get(path);
  }

  async deleteFile(path: string): Promise<void> {
    const storage = this.sdk.storage();
    await storage.delete(path);
  }
}

File Upload Controller

// src/lib/controllers/FileController.ts
import { BaseController } from './BaseController';
import { FileUploadService } from '$lib/services/FileUploadService';
import { json } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';

export class FileController extends BaseController {
  private fileService: FileUploadService;

  constructor(sdk: AsterismsBackendSDK) {
    super(sdk);
    this.fileService = new FileUploadService(sdk);
  }

  async uploadFile(event: RequestEvent) {
    try {
      const authCheck = await this.requireAuth(event);
      if (authCheck) return authCheck;

      const permissionCheck = await this.requirePermission('files.upload');
      if (permissionCheck) return permissionCheck;

      const formData = await event.request.formData();
      const file = formData.get('file') as File;

      if (!file) {
        return json({ error: 'No file provided' }, { status: 400 });
      }

      // Validate file
      const validationError = this.validateFile(file);
      if (validationError) {
        return json({ error: validationError }, { status: 400 });
      }

      const uploadPath = 'uploads';
      const result = await this.fileService.uploadFile(file, uploadPath);

      return json({ file: result }, { status: 201 });
    } catch (error) {
      return this.handleError(error, 'FileController.uploadFile');
    }
  }

  async getFile(event: RequestEvent) {
    try {
      const { params } = event;
      const filePath = decodeURIComponent(params.path);

      const fileBuffer = await this.fileService.getFile(filePath);
      if (!fileBuffer) {
        return json({ error: 'File not found' }, { status: 404 });
      }

      // Determine content type
      const contentType = this.getContentType(filePath);

      return new Response(fileBuffer, {
        headers: {
          'Content-Type': contentType,
          'Cache-Control': 'public, max-age=3600'
        }
      });
    } catch (error) {
      return this.handleError(error, 'FileController.getFile');
    }
  }

  private validateFile(file: File): string | null {
    const maxSize = 10 * 1024 * 1024; // 10MB
    const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf'];

    if (file.size > maxSize) {
      return 'File too large. Maximum size is 10MB.';
    }

    if (!allowedTypes.includes(file.type)) {
      return 'Invalid file type. Only JPEG, PNG, GIF, and PDF files are allowed.';
    }

    return null;
  }

  private getContentType(filePath: string): string {
    const extension = filePath.split('.').pop()?.toLowerCase();

    switch (extension) {
      case 'jpg':
      case 'jpeg':
        return 'image/jpeg';
      case 'png':
        return 'image/png';
      case 'gif':
        return 'image/gif';
      case 'pdf':
        return 'application/pdf';
      default:
        return 'application/octet-stream';
    }
  }
}

Real-time Controllers

WebSocket Controller

// src/lib/controllers/WebSocketController.ts
import { BaseController } from './BaseController';
import type { AsterismsBackendSDK } from '@asterisms/sdk-backend';

export class WebSocketController extends BaseController {
  private connections = new Map<string, WebSocket>();

  constructor(sdk: AsterismsBackendSDK) {
    super(sdk);
  }

  async handleConnection(request: Request, connectionId: string) {
    const { socket, response } = Deno.upgradeWebSocket(request);

    this.connections.set(connectionId, socket);

    socket.onopen = () => {
      console.log(`WebSocket connection opened: ${connectionId}`);
      this.sendMessage(connectionId, {
        type: 'connection',
        message: 'Connected successfully'
      });
    };

    socket.onmessage = (event) => {
      this.handleMessage(connectionId, JSON.parse(event.data));
    };

    socket.onclose = () => {
      console.log(`WebSocket connection closed: ${connectionId}`);
      this.connections.delete(connectionId);
    };

    socket.onerror = (error) => {
      console.error(`WebSocket error for ${connectionId}:`, error);
      this.connections.delete(connectionId);
    };

    return response;
  }

  private async handleMessage(connectionId: string, message: any) {
    try {
      switch (message.type) {
        case 'subscribe':
          await this.handleSubscribe(connectionId, message.channel);
          break;
        case 'unsubscribe':
          await this.handleUnsubscribe(connectionId, message.channel);
          break;
        case 'broadcast':
          await this.handleBroadcast(connectionId, message);
          break;
        default:
          this.sendMessage(connectionId, {
            type: 'error',
            message: 'Unknown message type'
          });
      }
    } catch (error) {
      console.error('WebSocket message handling error:', error);
      this.sendMessage(connectionId, {
        type: 'error',
        message: 'Failed to process message'
      });
    }
  }

  private async handleSubscribe(connectionId: string, channel: string) {
    // Store subscription in storage
    const storage = this.sdk.storage();
    const subscriptions = (await storage.get('subscriptions', connectionId)) || [];

    if (!subscriptions.includes(channel)) {
      subscriptions.push(channel);
      await storage.set('subscriptions', connectionId, subscriptions);
    }

    this.sendMessage(connectionId, {
      type: 'subscribed',
      channel
    });
  }

  private async handleUnsubscribe(connectionId: string, channel: string) {
    const storage = this.sdk.storage();
    const subscriptions = (await storage.get('subscriptions', connectionId)) || [];

    const updatedSubscriptions = subscriptions.filter((sub) => sub !== channel);
    await storage.set('subscriptions', connectionId, updatedSubscriptions);

    this.sendMessage(connectionId, {
      type: 'unsubscribed',
      channel
    });
  }

  private async handleBroadcast(connectionId: string, message: any) {
    const { channel, data } = message;

    // Get all connections subscribed to this channel
    const storage = this.sdk.storage();
    const allSubscriptions = await storage.list('subscriptions');

    for (const [connId, subscriptions] of allSubscriptions) {
      if (subscriptions.includes(channel) && connId !== connectionId) {
        this.sendMessage(connId, {
          type: 'broadcast',
          channel,
          data
        });
      }
    }
  }

  private sendMessage(connectionId: string, message: any) {
    const socket = this.connections.get(connectionId);
    if (socket && socket.readyState === WebSocket.OPEN) {
      socket.send(JSON.stringify(message));
    }
  }

  public broadcastToChannel(channel: string, data: any) {
    // This method can be called from other parts of the application
    // to broadcast messages to all subscribers of a channel
    this.connections.forEach((socket, connectionId) => {
      this.sendMessage(connectionId, {
        type: 'broadcast',
        channel,
        data
      });
    });
  }
}

Testing Controllers

Unit Tests

// src/lib/controllers/UserController.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { UserController } from './UserController';
import { UserService } from '$lib/services/UserService';

// Mock SDK
const mockSDK = {
  storage: () => ({
    list: vi.fn(),
    get: vi.fn(),
    create: vi.fn(),
    update: vi.fn(),
    delete: vi.fn()
  }),
  authorization: () => ({
    hasPermission: vi.fn().mockResolvedValue(true)
  })
};

describe('UserController', () => {
  let controller: UserController;
  let mockEvent: any;

  beforeEach(() => {
    controller = new UserController(mockSDK as any);
    mockEvent = {
      locals: {
        sdk: mockSDK,
        user: { id: 'user123' }
      },
      request: {
        json: vi.fn()
      },
      params: {}
    };
  });

  it('should get users successfully', async () => {
    const mockUsers = [
      { id: '1', email: 'user1@example.com', name: 'User 1' },
      { id: '2', email: 'user2@example.com', name: 'User 2' }
    ];

    mockSDK.storage().list.mockResolvedValue(mockUsers);

    const response = await controller.getUsers(mockEvent);
    const responseData = await response.json();

    expect(response.status).toBe(200);
    expect(responseData.users).toEqual(mockUsers);
  });

  it('should create user successfully', async () => {
    const userData = {
      email: 'newuser@example.com',
      name: 'New User',
      role: 'user'
    };

    const createdUser = { id: '3', ...userData };

    mockEvent.request.json.mockResolvedValue(userData);
    mockSDK.storage().create.mockResolvedValue(createdUser);

    const response = await controller.createUser(mockEvent);
    const responseData = await response.json();

    expect(response.status).toBe(201);
    expect(responseData.user).toEqual(createdUser);
  });

  it('should handle unauthorized access', async () => {
    mockEvent.locals.user = null;

    const response = await controller.getUsers(mockEvent);
    const responseData = await response.json();

    expect(response.status).toBe(401);
    expect(responseData.error).toBe('Unauthorized');
  });
});

Integration Tests

// src/routes/api/users/+server.test.ts
import { describe, it, expect } from 'vitest';
import { GET, POST } from './+server';

describe('/api/users', () => {
  it('should return users list', async () => {
    const mockEvent = {
      locals: {
        sdk: {
          storage: () => ({
            list: async () => [{ id: '1', email: 'test@example.com', name: 'Test User' }]
          })
        },
        user: { id: 'user123' }
      }
    };

    const response = await GET(mockEvent as any);
    const data = await response.json();

    expect(response.status).toBe(200);
    expect(data.users).toBeDefined();
    expect(data.users).toHaveLength(1);
  });

  it('should create new user', async () => {
    const userData = {
      email: 'newuser@example.com',
      name: 'New User',
      role: 'user'
    };

    const mockEvent = {
      locals: {
        sdk: {
          storage: () => ({
            create: async (collection, data) => ({ id: '2', ...data })
          }),
          authorization: () => ({
            hasPermission: async () => true
          })
        },
        user: { id: 'user123' }
      },
      request: {
        json: async () => userData
      }
    };

    const response = await POST(mockEvent as any);
    const data = await response.json();

    expect(response.status).toBe(201);
    expect(data.user.email).toBe(userData.email);
  });
});

Best Practices

Error Handling

// ✅ Good: Consistent error handling
export class UserController extends BaseController {
  async getUser(event: RequestEvent) {
    try {
      const user = await this.userService.findById(event.params.id);
      if (!user) {
        return json({ error: 'User not found' }, { status: 404 });
      }
      return json({ user });
    } catch (error) {
      return this.handleError(error, 'UserController.getUser');
    }
  }
}

// ❌ Bad: Inconsistent error handling
export const GET: RequestHandler = async ({ params }) => {
  try {
    const user = await getUserById(params.id);
    return json({ user });
  } catch (error) {
    // Different error handling in each route
    return json({ message: 'Error occurred' }, { status: 500 });
  }
};

Input Validation

// ✅ Good: Use schema validation
const validationResult = CreateUserDto.safeParse(requestData);
if (!validationResult.success) {
  return json(
    {
      error: 'Validation failed',
      details: validationResult.error.errors
    },
    { status: 400 }
  );
}

// ❌ Bad: Manual validation
if (!userData.email || !userData.name) {
  return json({ error: 'Missing fields' }, { status: 400 });
}

Resource Management

// ✅ Good: Proper resource cleanup
export class FileController extends BaseController {
  async uploadFile(event: RequestEvent) {
    let tempFile: string | null = null;

    try {
      const formData = await event.request.formData();
      const file = formData.get('file') as File;

      // Process file...

      return json({ success: true });
    } catch (error) {
      // Clean up temp file if it exists
      if (tempFile) {
        await fs.unlink(tempFile);
      }
      return this.handleError(error, 'FileController.uploadFile');
    }
  }
}

Next Steps