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.
.Header()
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.
.Cookie()
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) { ... }
.Cookie()
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 asortquery parameter..DefaultSort()— sets the default sort order when nosortparameter is provided..Filter(field, operators)— declares a filterable field and the allowed comparison operators. Adds afilterquery 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
| Operator | Description |
|---|---|
= | Equal |
!= | Not equal |
< | Less than |
<= | Less than or equal |
> | Greater than |
>= | Greater than or equal |
in | Value is in the given list |
!in | Value is not in the given list |
like | Case-sensitive pattern match (% wildcard) |
!like | Negated case-sensitive pattern match |
ilike | Case-insensitive pattern match |
!ilike | Negated 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],
})