Skip to main content
Version: 4.0.0-preview

Content Negotiation

ExpressoTS v4 ships with a content-negotiation engine that automatically selects the right response format based on the Accept header, and the right request parser based on Content-Type, without any explicit code in your handlers. Five formatters are bundled (JSON, XML, CSV, YAML, plain text) and you can register your own.

Quick start

app.ts
import { AppExpress } from "@expressots/adapter-express";

export class App extends AppExpress {
async configureServices(): Promise<void> {
this.Middleware.addContentNegotiation({
formatters: ["json", "xml", "csv", "yaml", "text"],
});
}
}
controller.ts
import { controller, Get, Produces } from "@expressots/adapter-express";

@controller("/users")
export class UserController {
@Get("/")
@Produces("application/json", "application/xml", "text/csv")
list() {
return this.usersService.findAll();
}
}
RequestResponse Content-TypeBody
GET /users (no Accept)application/jsonJSON-serialised array
GET /users Accept: application/xmlapplication/xmlXML-serialised array
GET /users Accept: text/csvtext/csvCSV with header row
GET /users Accept: text/yaml,application/json;q=0.5text/yamlYAML; quality value wins
GET /users Accept: image/png406 Not AcceptableNo formatter for image/*

Decorators

DecoratorSourcePurpose
@Accept(...types)@expressots/adapter-expressRestrict the handler to specific incoming Content-Types.
@Consumes(...types)@expressots/adapter-expressAlias of @Accept. Declares what the request body must be.
@Produces(...types)@expressots/adapter-expressDeclares what the response can be. Drives Accept negotiation.
@Version("v1")@expressots/adapter-expressURL / header / query versioning, orthogonal to negotiation but often paired.

@Produces

@Get("/")
@Produces("application/json", "application/xml")
listUsers() {
return [{ name: "Ada" }];
}

If the client's Accept header doesn't match any of the declared types, the framework returns 406 Not Acceptable. If multiple match, the highest quality value wins.

@Consumes / @Accept

@Post("/")
@Consumes("application/json", "application/xml")
createUser(@body() body: CreateUserDto) {
return this.usersService.create(body);
}

Requests with any other Content-Type are rejected with 415 Unsupported Media Type.

Built-in formatters

FormatterDefault Content-TypesOptions interfaceNotes
jsonapplication/json(none)Pretty-prints in development; compact in production.
xmlapplication/xml, text/xmlXmlOptionsElement-based; attribute mapping available via options.
csvtext/csvCsvOptionsHeader row, custom delimiter, custom quoting.
yamltext/yaml, application/yamlYamlOptionsIndent and line-width control.
texttext/plain(none)Calls .toString() on the response body.

XmlOptions

import { XmlOptions } from "@expressots/adapter-express";

this.Middleware.addContentNegotiation({
xml: {
rootName: "root",
indent: " ",
declaration: { version: "1.0", encoding: "UTF-8" },
cdata: ["description"],
} satisfies XmlOptions,
});

CsvOptions

import { CsvOptions } from "@expressots/adapter-express";

this.Middleware.addContentNegotiation({
csv: {
delimiter: ",",
quote: '"',
header: true,
columns: ["id", "name", "email"],
} satisfies CsvOptions,
});

YamlOptions

import { YamlOptions } from "@expressots/adapter-express";

this.Middleware.addContentNegotiation({
yaml: {
indent: 2,
lineWidth: 120,
noRefs: true,
} satisfies YamlOptions,
});

Streaming responses

For large payloads, return a StreamResponse so the framework streams data through the chosen formatter without buffering.

import { StreamResponse } from "@expressots/adapter-express";

@Get("/export")
@Produces("text/csv")
exportUsers() {
return new StreamResponse(async function* (write) {
for await (const user of usersService.cursor()) {
write(user);
}
});
}

The framework calls the generator function with a write helper that pushes one record through the formatter at a time. Backpressure is handled automatically.

Quality value handling

The framework parses Accept with full RFC 7231 quality value support:

Accept: text/csv;q=0.9, application/xml;q=0.5, application/json;q=1.0
StepResult
1. Parse[{json, q=1.0}, {csv, q=0.9}, {xml, q=0.5}]
2. Sort by qAlready sorted.
3. Match handlerHandler @Producesd JSON, CSV, XML.
4. Pick bestJSON, first match with highest q.

Custom formatters

Register your own formatter for a new content type:

import { FormatterRegistry } from "@expressots/adapter-express";

FormatterRegistry.register({
contentType: "application/x-msgpack",
serialize: (value) => msgpack.encode(value),
deserialize: (buf) => msgpack.decode(buf),
});

Once registered, the formatter participates in Accept negotiation exactly like the built-ins.

Composing with other systems

Other systemInteraction
InterceptorsNegotiation runs after interceptors return, so they see the raw return value.
ValidationValidation runs on the request body, before content negotiation parses the response.
Render enginesHTML responses route to the render engine, not the formatter registry.
StudioThe Studio Request Timeline records the negotiated response Content-Type.

See also