Skip to content

Jubiloop Controller & Routing Guide

Overview

This guide provides quick getting started examples on how to create and implement controllers, routes, validators, and DTOs in the Jubiloop backend application. For more detailed information, please refer to the AdonisJS documentation.

Table of Contents

Creating Controllers and Routes

When implementing new routes in Jubiloop, follow these general steps:

  1. Create validator(s) to validate incoming data
  2. Create DTO(s) to format outgoing data
  3. Create controller(s) with handlers for the specific operations
  4. Add routes to expose the controller endpoints
  5. Apply authentication middleware as needed

This guide provides examples based on the existing codebase. For comprehensive documentation, refer to the AdonisJS Controller Guide and AdonisJS Routing Guide.

Controllers

Controllers are the central components that handle HTTP requests, process business logic, and return standardized responses. They act as the bridge between routes and your application's models/services.

Controller Structure

Controllers in Jubiloop follow a class-based approach with well-defined methods for specific operations:

typescript
import { someValidator } from '#validators/some_validator'
import type { HttpContext } from '@adonisjs/core/http'

import SomeDto from '../dtos/some_dto.js'
import { renderSuccessResponsePayload } from '../lib/utils/response.js'

export default class SomeController {
  /**
   * Method description
   *
   * @param {HttpContext} context - The HTTP context
   */
  async someMethod({ request, response, auth }: HttpContext) {
    // Validate incoming data
    const payload = await request.validateUsing(someValidator)

    // Business logic
    const result = await SomeModel.create(payload)

    // Return response
    return response.created(
      renderSuccessResponsePayload({
        data: new SomeDto(result).minimal,
        message: 'Operation completed successfully!',
      })
    )
  }
}

Example: Sessions Controller

typescript
import User from '#models/user'
import { signInValidator } from '#validators/session'
import { HttpContext } from '@adonisjs/core/http'

import UserDto from '../dtos/user.js'
import { renderSuccessResponsePayload } from '../lib/utils/response.js'

export default class SessionsController {
  async signIn({ request, response, auth }: HttpContext) {
    const { email, password, rememberMe } = await request.validateUsing(signInValidator)

    const user = await User.verifyCredentials(email, password)

    await auth.use('web').login(user, !!rememberMe)

    response.created(
      renderSuccessResponsePayload({
        data: new UserDto(user).minimal,
        message: 'Logged in successfully',
      })
    )
  }
}

Validators

Validators ensure that incoming data meets specific criteria before processing.

Validator Structure

Validators in Jubiloop use VineJS for validation:

typescript
import vine from '@vinejs/vine'

export const someValidator = vine.compile(
  vine.object({
    field1: vine.string().trim().minLength(1),
    field2: vine.number().min(1),
    optionalField: vine.boolean().optional(),
  })
)

Custom Error Messages

typescript
import vine, { SimpleMessagesProvider } from '@vinejs/vine'

export const someValidator = vine.compile(
  vine.object({
    // validation rules
  })
)

someValidator.messagesProvider = new SimpleMessagesProvider({
  'field1.minLength': 'Field1 must be at least 1 character long',
})

Example: Registration Validator

typescript
import vine, { SimpleMessagesProvider } from '@vinejs/vine'

export const signUpValidator = vine.compile(
  vine.object({
    name: vine.string().trim().minLength(1),
    email: vine
      .string()
      .normalizeEmail({
        all_lowercase: true,
      })
      .unique({
        table: 'users',
        column: 'email',
      }),
    password: vine
      .string()
      .trim()
      .minLength(8)
      .regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]+$/)
      .confirmed({
        confirmationField: 'passwordConfirmation',
      }),
  })
)

signUpValidator.messagesProvider = new SimpleMessagesProvider({
  'password.regex':
    'Password must be at least 8 characters long, include one lowercase letter, one uppercase letter, one digit, and one special character (@$!%*?&).',
})

Data Transfer Objects (DTOs)

DTOs format and sanitize data for responses, ensuring consistent API interfaces and data security.

DTO Structure

typescript
import SomeModel from '#models/some_model'

type TMinimalDto = Pick<SomeModel, 'id' | 'name' | 'createdAt'>

export default class SomeDto {
  constructor(private readonly model: SomeModel) {}

  get minimal(): TMinimalDto {
    return this.model.serialize({
      fields: {
        pick: ['id', 'name', 'createdAt'],
      },
    }) as TMinimalDto
  }

  // Additional representations as needed
  get detailed() {
    // ...
  }
}

Example: User DTO

typescript
import User from '#models/user'

type TMinimalDto = Pick<User, 'id' | 'email' | 'name'>

export default class UserDto {
  constructor(private readonly user: User) {}

  get minimal(): TMinimalDto {
    return this.user.serialize({
      fields: {
        pick: ['id', 'email', 'name'],
      },
    }) as TMinimalDto
  }
}

Routes

Routes define the HTTP endpoints that clients can access in your application. They are defined in start/routes.ts and organized into public (unauthenticated) and authenticated groups.

Adding Public Routes

Public routes don't require authentication:

typescript
// In start/routes.ts
function getPublicRoutes() {
  return router.group(() => {
    // Existing routes
    router.post('/sign-in', [SessionsController, 'signIn'])

    // Add your new public routes here
    router.get('/some-public-data', [YourController, 'publicMethod'])
  })
}

Adding Protected Routes

Protected routes require user authentication:

typescript
// In start/routes.ts
function getAuthenticatedRoutes() {
  return router
    .group(() => {
      // Existing routes
      router.get('/validate-session', [SessionsController, 'validateSession'])

      // Add your new protected routes here
      router.get('/some-protected-data', [YourController, 'protectedMethod'])
      router.post('/some-resource', [YourController, 'createResource'])
    })
    .use(middleware.auth())
}

Route Structure

typescript
router
  .group(() => {
    router.get('/health', async ({ response }) => {
      response.ok({})
    })
    getAuthenticatedRoutes()
    getPublicRoutes()
  })
  .prefix('api')

For more details on AdonisJS routing, see the official documentation.

Authentication

Jubiloop uses Better Auth integrated with AdonisJS. Refer to the Authentication Documentation for details.

Authorization

Jubiloop uses AdonisJS Bouncer to control access to controller actions.

Authorization with Bouncer

Bouncer provides a way to define permission rules for your controllers:

  • Abilities: Functions suitable for simple authorization checks across controllers
  • Policies: Classes for controller-specific authorization logic, typically one per resource

To use Bouncer in your controllers:

typescript
// Using abilities
async someMethod({ bouncer, response }: HttpContext) {
  if (await bouncer.allows('editResource', resource)) {
    // User is authorized to edit
  } else {
    return response.forbidden()
  }
}

// Using policies
async someMethod({ bouncer, response }: HttpContext) {
  if (await bouncer.with(ResourcePolicy).denies('update', resource)) {
    return response.forbidden()
  }

  // Continue with authorized action
}

For more details, see the AdonisJS Authorization Documentation.

Response Structure

All controller responses in Jubiloop follow a standardized format to ensure consistency across the API:

typescript
export type TResponsePayload<TData, TMeta = any> = {
  data?: TData // Main response data
  messages?: TResponseMessage[] // Success/info messages
  errors?: TResponseError[] // Error messages
} & TMeta // Additional metadata

export type TResponseMessage = {
  title: string // Main message text
  description?: string // Optional details
}

export type TResponseError = {
  message: string // Error message text
}

Helper Functions

Controllers should use these utility functions to generate consistent responses:

typescript
// Success response with data and a message
renderSuccessResponsePayload({
  data: someData,
  message: 'Operation successful',
})

// Error response
renderErrorResponsePayload({
  errorMessage: 'Something went wrong',
})

Controller Response Examples

Success response from a controller:

json
{
  "data": {
    "id": "123e4567-e89b-12d3-a456-426614174000",
    "name": "Example Item"
  },
  "messages": [
    {
      "title": "Item created successfully"
    }
  ]
}

Error response from a controller:

json
{
  "errors": [
    {
      "message": "Validation failed"
    }
  ]
}

Complete Controller Example

Here's a complete example of a controller with standard patterns:

typescript
import Item from '#models/item'
import { itemValidator } from '#validators/item'
import { HttpContext } from '@adonisjs/core/http'

import ItemDto from '../dtos/item.js'
import { renderErrorResponsePayload, renderSuccessResponsePayload } from '../lib/utils/response.js'

export default class ItemsController {
  /**
   * List all items
   */
  async index({ response }: HttpContext) {
    const items = await Item.all()

    return response.ok(
      renderSuccessResponsePayload({
        data: items.map((item) => new ItemDto(item).minimal),
        message: 'Items retrieved successfully',
      })
    )
  }

  /**
   * Create a new item
   */
  async store({ request, response }: HttpContext) {
    try {
      const payload = await request.validateUsing(itemValidator)
      const item = await Item.create(payload)

      return response.created(
        renderSuccessResponsePayload({
          data: new ItemDto(item).minimal,
          message: 'Item created successfully',
        })
      )
    } catch (error) {
      return response.badRequest(
        renderErrorResponsePayload({
          errorMessage: 'Failed to create item',
        })
      )
    }
  }

  /**
   * Get a specific item
   */
  async show({ params, response }: HttpContext) {
    const item = await Item.find(params.id)

    if (!item) {
      return response.notFound(
        renderErrorResponsePayload({
          errorMessage: 'Item not found',
        })
      )
    }

    return response.ok(
      renderSuccessResponsePayload({
        data: new ItemDto(item).minimal,
        message: 'Item retrieved successfully',
      })
    )
  }
}

Built with ❤️ by the Jubiloop team