Sending Responses
An OPRA handler sends its response by returning a value. The adapter serializes it and writes the HTTP response — you rarely touch ctx.response directly, but it is available when you need fine-grained control.
Two ways to send a response
There are two ways to send an HTTP response from an OPRA handler.
The first is to return a value. The adapter intercepts the return value, serializes it, and writes the HTTP response. Any of the following can be returned: a plain object, an array, a string, a number, a boolean, a Buffer, a ReadableStream, a Blob, or null/undefined for an empty response.
async getOne(ctx: HttpContext) {
return { id: 1, name: 'Alice' };
}
async list(ctx: HttpContext) {
return [{ id: 1 }, { id: 2 }];
}
async ping(ctx: HttpContext) {
return 'pong';
}
async download(ctx: HttpContext) {
ctx.response.attachment('report.pdf');
return fs.createReadStream('/data/report.pdf');
}
The second is to write directly to ctx.response. This bypasses the adapter's serialization step entirely and is useful for long-lived connections such as Server-Sent Events:
async sseStream(ctx: HttpContext) {
const { response } = ctx;
response.status(200)
.setHeader('Content-Type', 'text/event-stream')
.setHeader('Cache-Control', 'no-cache');
const interval = setInterval(() => {
response.write(`data: ${Date.now()}\n\n`);
}, 1000);
ctx.request.once('close', () => {
clearInterval(interval);
response.end();
});
}
The adapter checks response.writableEnded after the handler returns. If the response has already been written, it skips its own serialization step.
Automatic Content-Type detection
When you return a value, the adapter sets the Content-Type header automatically based on the type of the returned value. Strings and numbers become text/plain; plain objects and arrays are serialized with JSON.stringify and sent as application/json; a Buffer is sent as application/octet-stream. A ReadableStream or Blob is piped directly — in that case the content type comes from the contentType declared in .Response().
Returning null or undefined when the current status code is 200 automatically promotes the response to 204 No Content with an empty body.
To override the inferred content type, call ctx.response.contentType() before returning:
async thumbnail(ctx: HttpContext) {
ctx.response.contentType('image/webp');
return this.service.getThumbnail(ctx.pathParams.id); // Buffer
}
The preferred approach is to declare contentType in .Response(), which makes the intent explicit in the schema:
@(HttpOperation.GET({ path: ':id/thumbnail' })
.PathParam('id', { type: 'integer' })
.Response(200, { contentType: 'image/webp' }))
async thumbnail(ctx: HttpContext) {
return this.service.getThumbnail(ctx.pathParams.id);
}
Status codes
The default status code is taken from the first declared 2xx response in .Response() and is applied before the handler runs. Override it at runtime via ctx.response.status():
@(HttpOperation.POST()
.Response(200)
.Response(201))
async upsert(ctx: HttpContext) {
const { created, entity } = await this.service.upsert(data);
ctx.response.status(created ? 201 : 200);
return entity;
}
Response headers
// Set a header
ctx.response.setHeader('X-Request-Id', requestId);
// Append to a multi-value header
ctx.response.appendHeader('Vary', 'Accept-Encoding');
// Override Content-Type
ctx.response.contentType('application/json');
// Set Content-Disposition for downloads
ctx.response.attachment('export.csv');
// Read an existing header
const ct = ctx.response.getHeader('content-type');
Cookies
ctx.response.cookie('session', token, {
httpOnly: true,
secure: true,
maxAge: 3_600_000, // 1 hour in ms
sameSite: 'strict',
path: '/',
});
ctx.response.clearCookie('session', { path: '/' });
Redirect
ctx.response.redirect('/new-location'); // 302
ctx.response.redirect(301, 'https://example.com/new'); // 301
OperationResult — model operation envelope
OperationResult is a response wrapper used by Entity template operations (Entity.Get, Entity.FindMany, Entity.Create, etc.). These operations declare application/opra.response+json as their content type, and the adapter automatically wraps the return value inside this envelope so that OPRA clients can parse it uniformly.
For plain HttpOperation.GET/POST operations with standard JSON responses you do not need OperationResult at all.
import { OperationResult } from '@opra/common';
| Field | Type | Description |
|---|---|---|
payload | T | The entity or list being returned. |
affected | number | boolean | Number of affected rows/documents (mutations and deletes). |
totalMatches | number | Total count for paginated results (used with FindMany). |
message | string | Optional human-readable message. |
context | string | Optional context string. |
errors | ErrorIssue[] | Error details (for error responses). |
OperationResult accepts arbitrary additional fields for any extra metadata you need.
Auto-wrapping
For Entity operations, returning a plain object is enough — the adapter wraps it in OperationResult.payload automatically:
@HttpOperation.Entity.Get(Customer)
async get(ctx: HttpContext) {
return this.service.findById(ctx.pathParams.customerId);
// → { payload: { id: 1, name: '...' } }
}
Explicit use — totalMatches
Use OperationResult directly when you need to include metadata alongside the payload:
@(HttpOperation.Entity.FindMany(Customer)
.Filter('active'))
async findMany(ctx: HttpContext) {
const { options } = await MongoAdapter.parseRequest(ctx);
if (options.count) {
const { items, count } = await this.service.findManyWithCount(options);
return new OperationResult({ payload: items, totalMatches: count });
}
return this.service.findMany(options);
}
affected — delete and update results
Entity.Delete, Entity.DeleteMany, and Entity.UpdateMany place the return value into OperationResult.affected. Return the affected count directly:
@HttpOperation.Entity.Delete(Customer)
async delete(ctx: HttpContext) {
const { key } = await MongoAdapter.parseRequest(ctx);
return this.service.delete(key); // number → { affected: n }
}
For Entity.Create and Entity.Update, the adapter defaults affected to 1 if you do not set it.