Appearance
Jubiloop Controller & Routing Guide
Overview
This guide provides quick getting started examples on how to create and implement controllers, routes, validators, and transformers 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:
- Create validator(s) to validate incoming data
- Create transformer(s) to format outgoing data
- Create controller(s) with handlers for the specific operations
- Add routes to expose the controller endpoints
- 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 type { HttpContext } from '@adonisjs/core/http'
import SomeTransformer from '#transformers/some_transformer'
import { someValidator } from '#validators/some_validator'
import { renderSuccessResponsePayload } from '../lib/utils/response.js'
export default class SomeController {
async someMethod({ request, response }: HttpContext) {
const payload = await request.validateUsing(someValidator)
const result = await SomeModel.create(payload)
return response.created(
renderSuccessResponsePayload({
data: new SomeTransformer(result).toObject(),
message: 'Operation completed successfully!',
}),
)
}
}Authentication Boundary
Jubiloop uses Better Auth, not AdonisJS auth. Authentication routes are handled by BetterAuthController and mounted at /auth/*. Do not create session controllers that call auth.use('web'); use app/lib/auth.ts and the Better Auth API instead.
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 (@$!%*?&).',
})Transformers
Transformers format model data for API responses using AdonisJS v7's BaseTransformer. They live in app/transformers/ and use this.pick() for field selection. Types are auto-generated in .adonisjs/client/data.d.ts.
Transformer Structure
typescript
import { BaseTransformer } from '@adonisjs/core/transformers'
import SomeModel from '#models/some_model'
export default class SomeTransformer extends BaseTransformer<SomeModel> {
toObject() {
return this.pick(this.resource, ['id', 'name', 'createdAt'])
}
forDetailed() {
return {
...this.toObject(),
updatedAt: this.resource.updatedAt.toJSDate().toISOString(),
}
}
}Example: User Transformer
typescript
import { BaseTransformer } from '@adonisjs/core/transformers'
import User from '#models/user'
export default class UserTransformer extends BaseTransformer<User> {
toObject() {
return this.pick(this.resource, ['id', 'email', 'name'])
}
}Usage in Controllers
typescript
// Default representation
data: new SomeTransformer(model).toObject()
// Named variant
data: new SomeTransformer(model).forDetailed()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.
Nested vs Shallow Routes
Use nested collection routes when the parent resource defines the creation scope. For example, creating a plan belongs under the organization-scoped event collection:
http
POST /organizations/:organizationId/events/:eventId/plansUse shallow member routes when the child resource ID is enough to identify the record and the parent path adds no extra authorization or lookup value.
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 interface IApiResponse<TData = unknown, TMeta = unknown> {
data?: TData // Main response data
messages?: TResponseMessage[] // Success/info messages
errors?: TResponseError[] // Error messages
meta?: TMeta // Optional response metadata
}
export type TResponseMessage = {
title: string // Main message text
description?: string // Optional details
}
export type TResponseError = {
message: string // Error message text
code: string // Three-part error code, e.g. common.resource.not_found
field?: string // Field name for field-specific errors
meta?: unknown // Optional client-safe metadata
}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',
code: 'common.server.internal',
})For Result error branches, prefer handleAppError() so app exceptions keep one response/logging path:
typescript
if (result.isErr()) {
return handleAppError(result.error, ctx, {
fallback: {
message: 'Failed to create event plan',
logKey: 'events:plans:create',
},
})
}App Exception Architecture
Server-owned exceptions separate client-facing API errors from backend-only logging details. The reference doc owns the detailed rules; controllers should only pass Result errors to handleAppError() and let each exception format its own API/log output.
For detailed exception semantics, repository factory defaults, service exception guidance, and duplicate constraint mapping, see .agents/references/server-error-architecture.md. Usage conventions are summarized in apps/server/AGENTS.md.
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",
"code": "input.name.required",
"field": "name"
}
]
}Complete Controller Example
Here's a simple CRUD example. For non-trivial flows, prefer services/repositories that return Result and pass error branches to handleAppError().
typescript
import { HttpContext } from '@adonisjs/core/http'
import Item from '#models/item'
import ItemTransformer from '#transformers/item_transformer'
import { itemValidator } from '#validators/item'
import { renderErrorResponsePayload, renderSuccessResponsePayload } from '../lib/utils/response.js'
export default class ItemsController {
async index(_ctx: HttpContext) {
const items = await Item.all()
return renderSuccessResponsePayload({
data: items.map((item) => new ItemTransformer(item).toObject()),
message: 'Items retrieved successfully',
})
}
async store({ request, response }: HttpContext) {
const payload = await request.validateUsing(itemValidator)
const item = await Item.create(payload)
response.status(201)
return renderSuccessResponsePayload({
data: new ItemTransformer(item).toObject(),
message: 'Item created successfully',
})
}
async show({ params, response }: HttpContext) {
const item = await Item.find(params.id)
if (!item) {
return response.notFound(
renderErrorResponsePayload({
errorMessage: 'Item not found',
code: 'common.resource.not_found',
}),
)
}
return renderSuccessResponsePayload({
data: new ItemTransformer(item).toObject(),
message: 'Item retrieved successfully',
})
}
}