Content Negotiation
ExpressoTS v4 ships with a content-negotiation engine that automatically selects the right response format based on the Accept header, without any explicit code in your handlers. Five formatters are bundled (JSON, XML, CSV, YAML, plain text) and you can register your own. On the request side, @Consumes restricts which Content-Types a route accepts.
Quick start
import { AppExpress } from "@expressots/adapter-express";
export class App extends AppExpress {
async configureServices(): Promise<void> {
this.Middleware.addContentNegotiation({
defaultFormat: "application/json",
});
}
}
All five built-in formatters are registered automatically; you don't list them by hand.
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();
}
}
| Request | Response Content-Type | Body |
|---|---|---|
GET /users (no Accept) | application/json | JSON-serialised array |
GET /users Accept: application/xml | application/xml | XML-serialised array |
GET /users Accept: text/csv | text/csv | CSV with header row |
GET /users Accept: text/yaml,application/json;q=0.5 | application/json | YAML isn't in @Produces; JSON is the best remaining match |
GET /users Accept: image/png | application/json | Falls back to defaultFormat; returns 406 only with strictMode: true |
Decorators
| Decorator | Source | Purpose |
|---|---|---|
@Produces(...types) | @expressots/adapter-express | Declares what the response can be. Drives Accept negotiation. |
@Accept(...types) | @expressots/adapter-express | Same as @Produces (@Produces is an alias of @Accept). Declares response types. |
@Consumes(...types) | @expressots/adapter-express | Declares what the request body must be. Enforced with 415 (see below). |
@Version("1") | @expressots/adapter-express | Path-based URL 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 response falls back to the defaultFormat (application/json by default). Set strictMode: true in addContentNegotiation to return 406 Not Acceptable instead. If multiple types match, the highest quality value wins.
@Consumes
@Post("/")
@Consumes("application/json", "application/xml")
createUser(@body() body: CreateUserDto) {
return this.usersService.create(body);
}
Requests carrying a Content-Type that doesn't match any declared type are rejected with 415 Unsupported Media Type before guards and validation run. The response body is application/problem+json:
{
"type": "https://expressots.dev/errors/unsupported-media-type",
"title": "Unsupported Media Type",
"status": 415,
"detail": "Content-Type \"text/plain\" is not supported by this route. Supported: application/json, application/xml",
"instance": "/users"
}
Requests without a Content-Type header are not rejected.
Built-in formatters
| Formatter | Content-Types | Options interface | Notes |
|---|---|---|---|
json | application/json | (none) | Compact output by default. |
xml | application/xml, text/xml | XmlFormatOptions | Element-based; attribute mapping available via options. |
csv | text/csv, application/csv | CsvFormatOptions | Header row, custom delimiter, field selection. |
yaml | application/yaml, text/yaml, application/x-yaml, text/x-yaml | YamlFormatOptions | Indent and line-width control. |
text | text/plain | (none) | Strings pass through; objects become key: value lines. |
The options interfaces are exported from @expressots/core. You can set app-wide defaults via the formatDefaults option:
this.Middleware.addContentNegotiation({
formatDefaults: {
xml: { rootElement: "root", prettyPrint: false, xmlDeclaration: true },
csv: { includeHeaders: true, delimiter: ",", escape: true },
yaml: { indent: 2, quoteStrings: false, lineWidth: 80 },
},
});
Per-route overrides use the @CsvOptions, @XmlOptions, and @YamlOptions decorators from @expressots/adapter-express.
XmlFormatOptions
import { Get, Produces, XmlOptions } from "@expressots/adapter-express";
@Get("/")
@Produces("application/xml")
@XmlOptions({
rootElement: "users",
itemElement: "user",
prettyPrint: true,
xmlDeclaration: true,
})
listUsers() {
return [{ id: 1, name: "Ada" }];
}
Fields: rootElement (default "root"), itemElement (default "item"), attributes, prettyPrint (default false), xmlDeclaration (default true), attributeMap.
CsvFormatOptions
import { Get, Produces, CsvOptions } from "@expressots/adapter-express";
@Get("/")
@Produces("text/csv")
@CsvOptions({
fields: ["id", "name", "email"],
includeHeaders: true,
delimiter: ",",
})
exportUsers() {
return this.usersService.findAll();
}
Fields: fields (all fields if omitted), includeHeaders (default true), delimiter (default ","), transform, escape (default true).
YamlFormatOptions
import { Get, Produces, YamlOptions } from "@expressots/adapter-express";
@Get("/")
@Produces("application/yaml")
@YamlOptions({
indent: 2,
lineWidth: 80,
})
getConfig() {
return this.configService.getAll();
}
Fields: indent (default 2), quoteStrings (default false), lineWidth (default 80).
Streaming responses
For large payloads, mark the route with the @StreamResponse() decorator and return an async iterator:
import { Get, Produces, StreamResponse } from "@expressots/adapter-express";
@Get("/export")
@Produces("text/csv")
@StreamResponse()
exportUsers() {
return this.usersService.streamLargeDataset(); // returns an async iterator
}
Quality value handling
The framework parses Accept with full RFC 7231 quality value support (enabled by default via the qualityValueSupport option):
Accept: text/csv;q=0.9, application/xml;q=0.5, application/json;q=1.0
| Step | Result |
|---|---|
| 1. Parse | [{json, q=1.0}, {csv, q=0.9}, {xml, q=0.5}] |
2. Sort by q | Already sorted. |
| 3. Match handler | Handler @Producesd JSON, CSV, XML. |
| 4. Pick best | JSON, first match with highest q. |
Wildcard (*/*) and partial (application/*) entries are also negotiated; control this with the negotiateWildcards and negotiatePartial options (both default true).
Custom formatters
Implement the IContentFormatter interface from @expressots/core and register the class via the customFormatters option:
import { IContentFormatter } from "@expressots/core";
export class MsgpackFormatter implements IContentFormatter {
canFormat(contentType: string): boolean {
return contentType === "application/x-msgpack";
}
format(data: unknown): Buffer {
return msgpack.encode(data);
}
getContentType(): string {
return "application/x-msgpack";
}
getSupportedTypes(): Array<string> {
return ["application/x-msgpack"];
}
}
this.Middleware.addContentNegotiation({
customFormatters: [MsgpackFormatter],
});
Once registered, the formatter participates in Accept negotiation exactly like the built-ins. For advanced scenarios, the underlying FormatterRegistry (exported from @expressots/core) exposes register(formatterClass), registerAll(formatterClasses), getFormatter(contentType), findFormatter(contentType), hasFormatter(contentType), and getAllFormatters(); you can reach the active registry through the content-negotiation service's getRegistry() method.
Configuration reference
addContentNegotiation accepts a ContentNegotiationOptions object (exported from @expressots/core):
| Option | Default | Purpose |
|---|---|---|
defaultFormat | "application/json" | Format used when Accept is missing or nothing matches. |
formatters | [] | Extra formatter classes (built-ins are always registered). |
customFormatters | [] | Custom formatter classes. |
strictMode | false | Return 406 instead of falling back to defaultFormat. |
negotiateWildcards | true | Negotiate */* Accept entries. |
negotiatePartial | true | Negotiate application/*-style entries. |
qualityValueSupport | true | Honour q= quality values. |
cacheFormatters | true | Cache formatter instances. |
preload | ["application/json"] | Content types whose formatters are instantiated eagerly. |
formatDefaults | see above | App-wide csv / xml / yaml option defaults. |
Composing with other systems
| Other system | Interaction |
|---|---|
| Interceptors | Negotiation runs after the handler returns, so interceptors see the raw return value. |
| Validation | Validation runs on the request body, before the response is formatted. |
| Render engines | HTML responses route to the render engine, not the formatter registry. |
| Studio | The Studio Request Timeline records the negotiated response Content-Type. |
See also
@Versiondecorator: combine versioning with negotiation.- Streaming + observability: Studio surfaces stream sizes per request.