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, 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

app.ts
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.

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.5application/jsonYAML isn't in @Produces; JSON is the best remaining match
GET /users Accept: image/pngapplication/jsonFalls back to defaultFormat; returns 406 only with strictMode: true

Decorators

DecoratorSourcePurpose
@Produces(...types)@expressots/adapter-expressDeclares what the response can be. Drives Accept negotiation.
@Accept(...types)@expressots/adapter-expressSame as @Produces (@Produces is an alias of @Accept). Declares response types.
@Consumes(...types)@expressots/adapter-expressDeclares what the request body must be. Enforced with 415 (see below).
@Version("1")@expressots/adapter-expressPath-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

FormatterContent-TypesOptions interfaceNotes
jsonapplication/json(none)Compact output by default.
xmlapplication/xml, text/xmlXmlFormatOptionsElement-based; attribute mapping available via options.
csvtext/csv, application/csvCsvFormatOptionsHeader row, custom delimiter, field selection.
yamlapplication/yaml, text/yaml, application/x-yaml, text/x-yamlYamlFormatOptionsIndent and line-width control.
texttext/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
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.

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"];
}
}
app.ts
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):

OptionDefaultPurpose
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.
strictModefalseReturn 406 instead of falling back to defaultFormat.
negotiateWildcardstrueNegotiate */* Accept entries.
negotiatePartialtrueNegotiate application/*-style entries.
qualityValueSupporttrueHonour q= quality values.
cacheFormatterstrueCache formatter instances.
preload["application/json"]Content types whose formatters are instantiated eagerly.
formatDefaultssee aboveApp-wide csv / xml / yaml option defaults.

Composing with other systems

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

See also