Skip to main content

HTTP Controllers

An HTTP controller is a class that groups related operations under a common base path. Each method on the class maps to an HTTP verb and path via @HttpOperation decorators. OPRA validates incoming requests and encodes responses automatically before your handler is ever called.


Defining a Controller

Decorate the class with @HttpController() and pass the base path for all operations in the controller.

import { HttpController } from '@opra/common';

@HttpController('/customers')
export class CustomersController {}

@HttpController() returns a chainable builder. Call additional methods on it to declare shared parameters, headers, cookies, and types that apply to every operation in the controller.

Declares a header that every operation in this controller accepts. OPRA validates and coerces the value before the handler runs.

@HttpController('/customers')
.Header('x-tenant-id', { type: 'string', required: true })
export class CustomersController {}

Access declared headers in the handler via ctx.request.headers.

Declares a cookie parameter shared across all operations.

@HttpController('/customers')
.Cookie('session', { type: 'string', required: true })
export class CustomersController {}

.QueryParam()

Declares a query parameter that applies to all operations. Useful for cross-cutting concerns such as locale, pagination defaults, or tenant context.

@HttpController('/customers')
.QueryParam('lang', { type: 'string' })
export class CustomersController {}

.PathParam()

Declares a path parameter shared by all operations — typically used when the controller itself is nested under a parameterised path segment.

@HttpController('/tenants/:tenantId/customers')
.PathParam('tenantId', { type: 'uid', required: true })
export class CustomersController {}

.KeyParam()

Declares the primary key parameter for the resource managed by this controller. KeyParam is used by entity operations — such as Get, Update, and Delete — to identify the target record.

@HttpController('/customers')
.KeyParam('id', { type: 'integer' })
export class CustomersController {}

.UseType()

Registers types with the controller scope so they are available in the schema without being declared globally in the ApiDocument. Useful for types that are only referenced within a single controller.

import { CustomerStatus } from '../models/enum/customer-status.js';

@HttpController('/customers')
.UseType(CustomerStatus)
export class CustomersController {}

Defining sub-controllers

A controller can nest child controllers under its base path using the controllers option. Each sub-controller is a fully independent controller with its own path, operations, and parameters.

import { HttpController } from '@opra/common';
import { OrdersController } from './orders.controller.js';
import { AddressesController } from './addresses.controller.js';

@HttpController('/customers', {
controllers: [OrdersController, AddressesController],
})
export class CustomersController {}

Sub-controller paths are resolved relative to the parent. OrdersController declared at /orders becomes accessible at /customers/orders.


Defining Operations

Each operation is declared with a static method on HttpOperation matching the HTTP verb. The first argument is the sub-path (omit it to bind to the controller's base path), followed by a chainable builder for parameters, body, and response.

import { HttpController, HttpOperation, ArrayType } from '@opra/common';
import { HttpContext } from '@opra/http';
import { Customer, CustomerCreate, CustomerPatch } from '../models/customer.js';

@HttpController('/customers')
export class CustomersController {
@HttpOperation.GET()
.Response(Customer)
async list(ctx: HttpContext) { ... }

@HttpOperation.GET(':id')
.PathParam('id', { type: 'integer' })
.Response(Customer)
async get(ctx: HttpContext) { ... }

@HttpOperation.POST()
.RequestBody(CustomerCreate)
.Response(Customer)
async create(ctx: HttpContext) { ... }

@HttpOperation.PATCH(':id')
.PathParam('id', { type: 'integer' })
.RequestBody(CustomerPatch)
.Response(Customer)
async update(ctx: HttpContext) { ... }

@HttpOperation.DELETE(':id')
.PathParam('id', { type: 'integer' })
async remove(ctx: HttpContext) { ... }
}

@HttpOperation returns a chainable builder. The sections below describe each builder method.

.PathParam()

Declares a path parameter for this operation. The value is coerced to the declared type before the handler runs.

@HttpOperation.GET(':id')
.PathParam('id', { type: 'integer' })
.Response(Customer)
async get(ctx: HttpContext) {
const id = ctx.pathParams.id; // number, not string
return this.service.findById(id);
}

.QueryParam()

Declares a query parameter. Values are validated and coerced against the declared type.

@HttpOperation.GET()
.QueryParam('limit', { type: 'integer' })
.QueryParam('offset', { type: 'integer' })
.QueryParam('search', { type: 'string' })
.Response(ArrayType(Customer))
async list(ctx: HttpContext) {
const { limit, offset, search } = ctx.queryParams;
return this.service.findAll({ limit, offset, search });
}

.Header()

Declares a header specific to this operation.

@HttpOperation.POST()
.Header('idempotency-key', { type: 'string', required: true })
.RequestBody(CustomerCreate)
.Response(Customer)
async create(ctx: HttpContext) { ... }

Declares a cookie parameter for this operation.

@HttpOperation.GET()
.Cookie('locale', { type: 'string' })
.Response(ArrayType(Customer))
async list(ctx: HttpContext) { ... }

.RequestBody()

Declares the expected request body type. The payload is validated and coerced before the handler is called. Invalid payloads are rejected with a structured error response.

@HttpOperation.POST()
.RequestBody(CustomerCreate)
.Response(Customer)
async create(ctx: HttpContext) {
const body = await ctx.request.getBody<CustomerCreate>();
return this.service.create(body);
}

.Response()

Declares the response type. Pass a model class for a single-object response or wrap it in an array for a list.

@HttpOperation.GET(':id')
.Response(Customer) // single object

@HttpOperation.GET()
.Response(ArrayType(Customer)) // array

To declare different response shapes for specific status codes, pass a status code as the first argument:

@HttpOperation.POST()
.RequestBody(CustomerCreate)
.Response(201, Customer)
.Response(409, { description: 'Customer already exists' })
async create(ctx: HttpContext) { ... }

.UseType()

Registers types scoped to this operation, without exposing them globally.

@HttpOperation.GET()
.UseType(CustomerStatus)
.Response(ArrayType(Customer))
async list(ctx: HttpContext) { ... }

Entity Operations

Entity operations are higher-level decorators built on top of @HttpOperation that implement the standard CRUD contract for a resource. They wire up the correct HTTP method, path, query parameters, request body, and response shape automatically. Use them instead of the raw @HttpOperation verbs when your handler maps directly to a data entity.

All entity operations take the entity type as their first argument.

import { HttpController, HttpOperation } from '@opra/common';
import { HttpContext } from '@opra/http';
import { Customer } from '../models/customer.js';

@HttpController('/customers')
.KeyParam('id', { type: 'integer' })
export class CustomersController {
@HttpOperation.Entity.Create(Customer)
async create(ctx: HttpContext) { ... }

@HttpOperation.Entity.Get(Customer)
async get(ctx: HttpContext) { ... }

@HttpOperation.Entity.FindMany(Customer)
async findMany(ctx: HttpContext) { ... }

@HttpOperation.Entity.Update(Customer)
async update(ctx: HttpContext) { ... }

@HttpOperation.Entity.UpdateMany(Customer)
async updateMany(ctx: HttpContext) { ... }

@HttpOperation.Entity.Replace(Customer)
async replace(ctx: HttpContext) { ... }

@HttpOperation.Entity.Delete(Customer)
async delete(ctx: HttpContext) { ... }

@HttpOperation.Entity.DeleteMany(Customer)
async deleteMany(ctx: HttpContext) { ... }
}

Create

HttpOperation.Entity.Create maps to POST /. The request body is validated against the entity type. Responds with 201 Created on success.

@HttpOperation.Entity.Create(Customer)
async create(ctx: HttpContext) { ... }

Get

HttpOperation.Entity.Get maps to GET @:id. Uses the key parameter declared on the controller. Responds with 200 OK or 204 No Content.

@HttpOperation.Entity.Get(Customer)
async get(ctx: HttpContext) { ... }

Override the key parameter for this operation specifically:

@HttpOperation.Entity.Get(Customer)
.KeyParam('id', { type: 'integer' })
async get(ctx: HttpContext) { ... }

FindMany

HttpOperation.Entity.FindMany maps to GET /. Automatically adds limit, skip, count, and projection query parameters.

@HttpOperation.Entity.FindMany(Customer)
.SortFields('name', 'createdAt')
.DefaultSort('name')
.Filter('name', '=,!=,like')
.Filter('status', ['=', '!='])
async findMany(ctx: HttpContext) { ... }
  • .SortFields() — declares which fields the client can sort by. Adds a sort query parameter.
  • .DefaultSort() — sets the default sort order when no sort parameter is provided.
  • .Filter(field, operators) — declares a filterable field and the allowed comparison operators. Adds a filter query parameter.

Update

HttpOperation.Entity.Update maps to PATCH @:id. Supports patch operators and null optionals in the request body.

@HttpOperation.Entity.Update(Customer)
.KeyParam('id', { type: 'integer' })
.Filter('status', ['=', '!='])
async update(ctx: HttpContext) { ... }

UpdateMany

HttpOperation.Entity.UpdateMany maps to PATCH /. Updates all records matching the filter.

@HttpOperation.Entity.UpdateMany(Customer)
.Filter('status', ['=', '!='])
async updateMany(ctx: HttpContext) { ... }

Replace

HttpOperation.Entity.Replace maps to PUT @:id. Replaces the entire entity document. Responds with 200 OK or 204 No Content.

@HttpOperation.Entity.Replace(Customer)
.KeyParam('id', { type: 'integer' })
async replace(ctx: HttpContext) { ... }

Delete

HttpOperation.Entity.Delete maps to DELETE @:id. Responds with 200 OK including an affected count.

@HttpOperation.Entity.Delete(Customer)
.KeyParam('id', { type: 'integer' })
async delete(ctx: HttpContext) { ... }

DeleteMany

HttpOperation.Entity.DeleteMany maps to DELETE /. Deletes all records matching the filter.

@HttpOperation.Entity.DeleteMany(Customer)
.Filter('status', ['=', '!='])
async deleteMany(ctx: HttpContext) { ... }

Entity Filtering

.Filter() declares which fields a client can filter on and which comparison operators are allowed for each. It adds a filter query parameter to the operation. Only explicitly declared fields are accepted — any attempt to filter on an undeclared field is rejected with a structured error.

When used with a data service, the parsed filter expression is automatically translated into the native query format of the underlying database — a MongoDB $match stage, a SQL WHERE clause, or an Elasticsearch query block — without any manual wiring.

Declaring filter fields

Pass the field name and the allowed operators as an array or a comma-separated string:

@HttpOperation.Entity.FindMany(Customer)
.Filter('name', ['=', '!=', 'like', 'ilike'])
.Filter('status', '=,!=')
.Filter('age', ['=', '!=', '<', '<=', '>', '>='])
.Filter('tags', ['in', '!in'])
async findMany(ctx: HttpContext) { ... }

When no operators are provided, = and != are used by default:

.Filter('status') // allows = and != only

Available operators

OperatorDescription
=Equal
!=Not equal
<Less than
<=Less than or equal
>Greater than
>=Greater than or equal
inValue is in the given list
!inValue is not in the given list
likeCase-sensitive pattern match (% wildcard)
!likeNegated case-sensitive pattern match
ilikeCase-insensitive pattern match
!ilikeNegated case-insensitive pattern match

Field name mapping

To expose an API field name that differs from the underlying data field, use the field:mappedField colon notation or pass mappedField in the options object:

.Filter('fullName:name', ['=', 'like'])
// or equivalently:
.Filter('fullName', { operators: ['=', 'like'], mappedField: 'name' })

The client uses fullName in the filter expression; OPRA rewrites it to name before passing it to the data layer.

Client filter syntax

Clients send filters as a filter query parameter using OPRA filter expression syntax. Logical and and or are supported:

GET /customers?filter=status='active' and age>=18
GET /customers?filter=name like 'John%' or name like 'Jane%'
GET /customers?filter=status in ['active','trial']
GET /customers?filter=(status='active' or status='trial') and (age>=18 and age<=65)

NestJS Integration

In a NestJS application, OPRA controllers are also NestJS providers. @HttpController() applies @Injectable() automatically, so you can inject services through the constructor as usual.

import { HttpController, HttpOperation } from '@opra/common';
import { HttpContext } from '@opra/http';
import { CustomersService } from '../services/customers.service.js';
import { Customer } from '../models/customer.js';

@HttpController('/customers')
export class CustomersController {
constructor(private readonly service: CustomersService) {}

@HttpOperation.GET({ response: ArrayType(Customer) })
async list(ctx: HttpContext) {
return this.service.findAll();
}
}

Register both the controller and its dependencies in OpraHttpModule.forRoot():

OpraHttpModule.forRoot({
controllers: [CustomersController],
providers: [CustomersController, CustomersService],
})