Skip to main content

Overview

Controllers in Loopar handle HTTP requests and coordinate between the routing layer and document models. They follow a convention-based system where actions map to methods, providing a clean MVC-like architecture.

Controller Hierarchy

Loopar provides several controller types in an inheritance chain:

BaseController

The BaseController provides core CRUD operations:
packages/loopar/core/controller/base-controller.js
export default class BaseController extends CoreController {
  defaultAction = 'list';
  hasSidebar = true;

  async actionList() {
    if (this.hasData()) {
      await loopar.session.set(this.document + '_q', this.data.q || {});
      await loopar.session.set(this.document + '_page', this.data.page || 1);
    }

    const data = Object.entries({ ...loopar.session.get(this.document + '_q') || {} })
      .reduce((acc, [key, value]) => {
        if (value && (value.toString()).length > 0 && value !== 0) {
          acc[key] = `${value}`;
        }
        return acc;
      }, {});

    const list = await loopar.getList(this.document, { 
      data, 
      q: (data && Object.keys(data).length > 0) ? data : null 
    });

    return await this.render(list);
  }

  async actionCreate() {
    const document = await loopar.newDocument(this.document, this.data);

    if (document.__ENTITY__.is_single) {
      return loopar.throw({
        code: 404,
        message: "This document is single, you can't create new"
      });
    }

    if (this.hasData()) {
      await document.save();
      return this.redirect('update?name=' + document.name);
    } else {
      Object.assign(this.response, await document.__meta__());
      return await this.render(this.response);
    }
  }

  async actionUpdate(document) {
    document ??= await loopar.getDocument(
      this.document, 
      this.name, 
      this.hasData() ? this.data : null
    );

    if (this.hasData()) {
      const Entity = document.__ENTITY__;
      const isSingle = Entity.is_single;
      await document.save();

      return await this.success(
        `${Entity.name} ${isSingle ? Entity.name : document.name} saved successfully`, 
        { name: document.name }
      );
    } else {
      return await this.render({ ...await document.__meta__(), ...this.response || {} });
    }
  }

  async actionDelete() {
    const document = await loopar.getDocument(this.document, this.name);
    await document.delete();

    return this.redirect('list');
  }

  async actionBulkDelete() {
    const names = loopar.utils.isJSON(this.names) ? JSON.parse(this.names) : [];

    if (Array.isArray(names)) {
      for (const name of names) {
        const document = await loopar.getDocument(this.document, name);
        await document.delete();
      }
    }

    return this.success(`Documents ${names.join(', ')} deleted successfully`);
  }
}

Controller Actions

Actions are methods prefixed with action that handle specific operations:
Display a list of documents
async actionList() {
  const list = await loopar.getList(this.document, { 
    data, 
    q: searchQuery 
  });
  return await this.render(list);
}
  • Handles pagination and search
  • Stores query state in session
  • Returns formatted list with metadata

Specialized Controllers

FormController

For read-only form views:
packages/loopar/core/controller/form-controller.js
export default class FormController extends BaseController {
  constructor(props) {
    super(props);
    this.action !== 'view' && this.redirect('view');
  }

  async actionView() {
    const document = await loopar.getDocument(this.document, this.name);
    return await this.render(document);
  }
}
FormController automatically redirects all actions to view, making it perfect for read-only displays.

SingleController

For singleton documents (only one instance exists):
packages/loopar/core/controller/single-controller.js
export default class SingleController extends BaseController {
  client = "view";
  
  constructor(props) {
    super(props);
    this.action !== 'view' && this.redirect('view');
  }
}
Use cases:
  • System Settings
  • Application Configuration
  • User Preferences

PageController

For public-facing pages:
packages/loopar/core/controller/page-controller.js
export default class PageController extends SingleController {
  client = 'page';
  
  constructor(props) {
    super(props);
  }
}
Features:
  • No authentication required
  • Custom client rendering
  • Public access

ViewController

For custom view logic:
packages/loopar/core/controller/view-controller.js
export default class SingleController extends BaseController {
  client = "view";
  
  constructor(props) {
    super(props);
    this.action !== 'view' && this.redirect('view');
  }
}

Custom Controllers

Create custom controllers by extending base classes:
import BaseController from '@loopar/core/controller/base-controller.js';

export default class UserController extends BaseController {
  constructor(props) {
    super(props);
  }

  // Override default action
  async actionUpdate(document) {
    document ??= await loopar.getDocument(this.document, this.name, this.data);

    if (this.hasData()) {
      // Custom pre-save logic
      if (document.password && document.password !== document.protectedPassword) {
        document.password = loopar.hash(document.password);
      }
      
      await document.save();
      return await this.success('User saved successfully');
    }

    return await this.render(await document.__meta__());
  }

  // Custom action
  async actionResetPassword() {
    const user = await loopar.getDocument('User', this.name);
    
    const newPassword = loopar.utils.randomString(12);
    user.password = loopar.hash(newPassword);
    await user.save();

    // Send email
    await this.sendPasswordResetEmail(user, newPassword);

    return this.success('Password reset email sent');
  }

  // Custom action for AJAX
  async actionGetPermissions() {
    const user = await loopar.getDocument('User', this.name);
    const permissions = await user.getPermissions();
    
    return { permissions };
  }

  async sendPasswordResetEmail(user, password) {
    // Email logic
  }
}

Request Flow

Here’s how requests flow through controllers:
packages/loopar/core/server/router/router.js
async executeController(req, res, next, params, ref) {
  const makeController = async (query, body) => {
    // Import controller class
    const C = await fileManage.importClass(
      loopar.makePath(ref.__ROOT__, `${params.document}Controller.js`)
    );

    // Create controller instance
    const Controller = new C({
      ...params,
      ...query,
      data: RouterUtils.prepareFileData(body, req.files),
      __REQ_FILES__: req.files,
    });

    // Determine action
    const action = params.action?.length > 0 ? params.action : Controller.defaultAction;
    Controller.action = action;

    // Execute action
    const result = await Controller.sendAction(action) || {};

    // Handle result
    if (req.method === 'POST' || result.redirect) {
      req.__WORKSPACE__ = result;
    } else {
      req.__WORKSPACE__ = merge(
        req.__WORKSPACE__ || {},
        {
          Document: merge(result, {
            meta: { module: ref?.module }
          })
        }
      );
    }
  };

  // Handle multipart form data
  const contentType = req.headers['content-type'];
  const isMultipart = RouterUtils.isMultipartFormData(contentType);

  if (isMultipart) {
    return new Promise((resolve, reject) => {
      this.uploader(req, res, async err => {
        if (err) reject(err);
        try {
          resolve(await makeController(req.query, req.body));
        } catch (controllerErr) {
          reject(controllerErr);
        }
      });
    });
  }

  return await makeController(req.query, req.body);
}

Controller Properties

Controllers have access to several properties:
this.data          // POST body data
this.files         // Uploaded files
this.query         // Query parameters
this.name          // Document name from URL
this.document      // Document type
this.action        // Current action

Response Methods

Rendering

// Render with SSR
return await this.render({
  Entity: { name: 'User', doc_structure: '...' },
  data: { name: 'john', email: 'john@example.com' },
  isNew: false
});

Redirecting

// Relative redirect
return this.redirect('list');

// Absolute redirect
return this.redirect('/desk/User/list');

// With query params
return this.redirect('update?name=' + document.name);

AJAX Responses

// Success response
return this.success('Operation completed', { 
  id: 123, 
  name: 'Result' 
});

// Error response
loopar.throw({
  code: 400,
  message: 'Validation failed'
});

// JSON response
return {
  status: 'success',
  data: results,
  count: results.length
};

File Uploads

Controllers automatically handle file uploads:
async actionUpload() {
  const files = this.files; // Array of uploaded files
  
  for (const file of files) {
    const fileManager = await loopar.newDocument('File Manager');
    fileManager.reqUploadFile = file;
    fileManager.app = this.__APP__;
    await fileManager.save();
  }

  return this.success('Files uploaded successfully');
}

Middleware Integration

Controllers integrate with the middleware stack:
// Authentication check
if (!loopar.currentUser?.name) {
  return this.redirect('/auth/login');
}

// Permission check
const hasPermission = await this.checkPermission('delete');
if (!hasPermission) {
  loopar.throw({
    code: 403,
    message: 'You do not have permission to delete'
  });
}

// Rate limiting
await this.checkRateLimit();

Testing Controllers

import UserController from './UserController.js';

describe('UserController', () => {
  it('should create user', async () => {
    const controller = new UserController({
      document: 'User',
      action: 'create',
      data: {
        name: 'test_user',
        email: 'test@example.com'
      }
    });

    const result = await controller.actionCreate();
    expect(result.name).toBe('test_user');
  });

  it('should handle validation errors', async () => {
    const controller = new UserController({
      document: 'User',
      action: 'create',
      data: {
        name: 'test_user'
        // Missing required email
      }
    });

    await expect(controller.actionCreate()).rejects.toThrow('email is required');
  });
});

Best Practices

Security Considerations
  • Always validate user input
  • Check permissions before sensitive operations
  • Sanitize data before database operations
  • Use transactions for multi-step operations
Performance Tips
  • Cache frequently accessed data
  • Use preloaded mode for faster responses
  • Minimize database queries in loops
  • Implement pagination for large datasets

Common Patterns

Preloaded Mode

if (this.preloaded == 'true') {
  return {
    instance: this.getInstance(),
    data: await document.values()
  }
}

Conditional Logic

if (this.hasData()) {
  // Handle POST request
  await document.save();
  return this.success('Saved');
} else {
  // Handle GET request
  return this.render(await document.__meta__());
}

Error Handling

try {
  await document.save();
  return this.success('Saved successfully');
} catch (error) {
  loopar.throw({
    code: 400,
    message: error.message
  });
}

Next Steps

Routing

Learn how URLs map to controllers

Documents

Understand the document system

Build docs developers (and LLMs) love