Content negotiation
[Produces] names the media types that an operation answers with. When an operation declares two or more, the request's Accept header picks one.
using Hardened.Requests.Abstract.Attributes;
using Hardened.Web.Runtime.Attributes;
namespace Todos;
public class TodoController
{
[Operation("listTodos")]
[Get("/")]
[Produces("application/json", "text/csv")]
public Task<IReadOnlyList<Todo>> All(ITodoStore store) => store.All();
}GET /todos
HTTP/1.1 200 OK
Content-Type: application/json
[{"id":1,"title":"Read the generated code","done":true},{"id":2,"title":"Add an endpoint","done":false}]GET /todos
Accept: text/csv
HTTP/1.1 200 OK
Content-Type: text/csv
id,title,done
1,Read the generated code,True
2,Add an endpoint,FalseCsvResponseSerializer writes the text/csv response. Writing a serializer shows its code. An operation that declares nothing answers JSON.
Declaring what an operation produces
[Produces] is in the Hardened.Requests.Abstract.Attributes namespace, in the Hardened.Requests.Abstract package. It takes one media type or several, in the order that the operation prefers them. A media type can be a constant, such as KnownContentType.Json from Hardened.Requests.Abstract.Headers.
A declaration goes on a method, on its class or on the handler's assembly. An application can also register a ResponseContentTypeDefault, from Hardened.Requests.Abstract.Serializer. The nearest declaration wins, in this order:
| Declared on | Written as |
|---|---|
| The method | [Produces("text/csv")] |
| Its class | [Produces("text/csv")] on the class |
| The handler's assembly | [assembly: Produces("text/csv")] |
| The application | services.AddSingleton(new ResponseContentTypeDefault("text/csv")) |
Declarations are not combined. [assembly: Produces] covers the handlers compiled in that assembly. Declared in the host project, it does not cover the library's handlers. A registered default covers every handler, in every assembly, that declares nothing on its method, its class or its assembly.
The OpenAPI document reads the declarations on the method and the class only. An operation covered by [assembly: Produces] or by a registered default appears in the document under application/json alone. The OpenAPI document covers what a declared operation publishes.
A contract declares the media types of an operation with the content: keys of its responses. Generating from OpenAPI covers contracts.
Return types
The return type and the declared media types decide how the response is written:
| The handler returns | It declares | The response |
|---|---|---|
| A model | Nothing | JSON |
| A model | One or more media types | Written by the serializer for the media type that the request gets |
string | Nothing | Written by the JSON serializer as a JSON string, such as "Hello" |
string | One or more media types | The string unchanged, under the media type that the request gets |
byte[] or Stream | One media type (HRDR011 without one) | The bytes unchanged, under that media type |
byte[] or Stream | Two or more media types | The bytes unchanged, under the content type that the handler sets, or application/octet-stream |
IAsyncEnumerable<T> | Anything | Framed by the streaming filter |
WARNING
A handler that returns string and declares application/json sends the string unchanged, not as a JSON string. To send JSON, return a model or declare nothing.
A handler that returns byte[] or Stream must declare its media type on the method or on its class. Without one, the build fails with HRDR011. [assembly: Produces] does not satisfy the check. The check applies to the success case of a response set. Response<byte[], NotFound> needs [Produces] too. This handler in src/Todos/ExportController.cs returns byte[] and declares nothing:
using Hardened.Web.Runtime.Attributes;
namespace Todos;
public class ExportController
{
[Get("/export")]
public byte[] Export() => [];
}The build reports the error at the handler's name:
src/Todos/ExportController.cs(8,19): error HRDR011: 'ExportController.Export' answers with byte[] or Stream and carries no [Produces], so nothing says what the bytes are. Answering with either means the handler writes its own response, and no serializer is consulted - declare the media type with [Produces("application/pdf")] or answer with a model.A handler that returns byte[] or Stream and declares two or more media types is not negotiated. Choosing the content type per request shows how a handler sets the content type.
A handler that returns IAsyncEnumerable<T> answers in its stream's media type, and a request that names other types only gets 406 under Strict. Streaming responses covers it. A handler with [Output<T>] renders a view whatever Accept says. Views covers it.
Matching the Accept header
Under the default mode, Strict, the operation decides whether Accept is read:
| The operation | Accept is read | A request that names none of its media types |
|---|---|---|
| Declares nothing | No | Answered with JSON |
Returns string, byte[] or Stream, and declares one media type | No | Answered with that media type |
Returns byte[] or Stream, and declares two or more | No | Answered with the content type the handler sets, or application/octet-stream |
| Returns a model and declares one media type | Yes | 406 |
Returns a model or string, and declares two or more | Yes | 406 |
A contract operation that declares one text media type is matched like a model. It answers 406 to Accept: application/json.
The entries of the header are tried in the order that the client wrote them. The first entry that the operation produces wins. Parameters on an entry are ignored, including q. Media types are compared without regard to case. The response's Content-Type is the declared media type that matched. For an operation with [Produces("application/json", "text/plain")]:
Accept | Answered with |
|---|---|
Absent, empty or */* | application/json, the first declared type |
text/plain or TEXT/PLAIN | text/plain |
text/* | text/plain |
application/* | application/json |
text/plain;q=0.5, application/json;q=0.9 | text/plain, the first entry |
image/png, text/plain | text/plain, the first entry it produces |
image/png | 406 |
A request that names nothing the operation produces gets 406 Not Acceptable. Its body names the media types that the operation produces. A media type that only the contract's error responses declare, such as application/problem+json, is left out. The body is JSON. The listTodos operation from the opening example answers Accept: application/xml like this:
GET /todos
Accept: application/xml
HTTP/1.1 406 Not Acceptable
Content-Type: application/problem+json
{"detail":"This operation produces application/json, text/csv.","type":"urn:hardened:problem:not-acceptable","title":"Not Acceptable","status":406}The OpenAPI document lists no 406. The response to an operation that negotiates carries no Vary: Accept header. On a cached operation, the response cache adds one. Response caching covers it.
Answering without a 406
ContentNegotiationMode, in Hardened.Requests.Abstract.Serializer, has two values:
| Mode | A request that names none of the declared media types |
|---|---|
Strict | 406, with a body naming what the operation produces. The default |
Lenient | The declared type when there is one, otherwise JSON |
Under Lenient, an operation that declares one media type answers with it without reading Accept. An operation that declares two or more answers a request that names none of them with JSON. No request gets a 406.
The mode applies to the whole application. Register an IContentNegotiationPolicy in the ConfigureServices of a module to set it:
using DependencyModules.Runtime.Interfaces;
using Hardened.Requests.Abstract.Serializer;
using Hardened.Shared.Runtime.Attributes;
using Hardened.Web.Runtime.Attributes;
using Hardened.Web.Runtime.DependencyInjection;
using Hardened.Web.Runtime.OpenApi;
using Microsoft.Extensions.DependencyInjection;
using System.Text.Json.Serialization.Metadata;
namespace Todos;
[HardenedModule]
[HardenedWebModule]
[BasePath("/todos")]
[Server("http://localhost:5080", "Local")]
[Enable<OpenApiDocumentPublishing>]
public partial class TodosLibrary : IServiceCollectionConfiguration
{
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<IJsonTypeInfoResolver>(TodosJsonContext.Default);
services.AddSingleton<IContentNegotiationPolicy>(
new ContentNegotiationPolicy(ContentNegotiationMode.Lenient)
);
}
}With that module, the request from the 406 example gets JSON:
GET /todos
Accept: application/xml
HTTP/1.1 200 OK
Content-Type: application/json
[{"id":1,"title":"Read the generated code","done":true},{"id":2,"title":"Add an endpoint","done":false}]A contract sets Lenient with x-hardened-content-negotiation: lenient at its root.
A media type that nothing writes
The build warns with HRDR012 when a handler returns a model and declares a media type that no serializer known to the build writes. application/json is always known. The build reads what each serializer writes from [assembly: WritesContentType], in Hardened.Requests.Abstract.Attributes. It does not read ContentType from a serializer class, even one in the same project. The build looks for the attribute in the project and in every assembly that the project references. A serializer in another project or in a package counts. Without the CSV serializer's [assembly: WritesContentType] line, the build warns for each handler that declares text/csv:
CSC : warning HRDR012: 'TodoController.All' declares [Produces("text/csv")] and returns a model, and nothing here writes a model as that media type. Register an IResponseSerializer declaring it, or return string, byte[] or Stream and write the bytes yourself. A host that registers one makes this correct, which is why it is a warning.At run time, when no serializer is registered for the media type, a request to such an operation gets a 500 with an empty body. The log names ContentTypeNotProducibleException: "This operation declares text/csv and no registered serializer can produce any of them."
The build makes the same check for an operation that an OpenAPI or Smithy contract describes. It warns for each media type in the contract's responses that nothing writes:
CSC : warning HRDR012: 'ITodosService.GetTodo' is declared by its contract as answering application/xml, and nothing here writes a model as that media type. Register an IResponseSerializer declaring it, or reference the package that writes it. A host that registers one makes this correct, which is why it is a warning.application/problem+json is known for a failure, because the JSON serializers write it for a status of 400 or above. For a success it is not, so a contract that declares a success as application/problem+json gets HRDR012. A success that the handler writes as text or bytes is not checked, and neither is text/html on a success, which a view writes.
Error bodies
When an operation negotiates, a failed request gets its error body in the representation that the request negotiated. The serializer receives the error body as the value to write. The value is one of these types:
| Type | Namespace |
|---|---|
NotFound | Hardened.Web.Runtime.Responses |
RequestValidationError | Hardened.Requests.Runtime.Validation |
ErrorModel | Hardened.Requests.Abstract.Errors |
The getTodo operation declares the same two media types as listTodos:
using Hardened.Requests.Abstract.Attributes;
using Hardened.Requests.Abstract.Responses;
using Hardened.Web.Runtime.Attributes;
using Hardened.Web.Runtime.Responses;
using ValidationModules.Constraints;
namespace Todos;
public class TodoController
{
[Operation("getTodo")]
[Get("/{id}")]
[Produces("application/json", "text/csv")]
public async Task<Response<Todo, NotFound>> ById(ITodoStore store, [Range(Min = 1)] int id)
{
var todo = await store.Find(id);
if (todo is null)
{
return new NotFound("todo", $"No todo has id {id}.");
}
return todo;
}
}A request for a missing todo gets its 404 as CSV, with the header row alone:
GET /todos/99
Accept: text/csv
HTTP/1.1 404 Not Found
Content-Type: text/csv
id,title,doneA handler that returns string, byte[] or Stream answers its failures as JSON.
A failure whose body is a problem type, such as NotFound, and that is written as JSON goes out as application/problem+json. Responses covers the problem types and RFC 9457.
A contract can declare its failures in media types of their own. For example, application/json for a 200 and application/problem+json for the 404. A failure is then negotiated within the media types the contract declares for failures. A client that asks for application/json gets the 404 as application/problem+json, because a problem document is JSON. The JSON serializers write application/problem+json for a failure only, so a client that asks for it on the 200 gets a 406 under the strict mode and application/json under the lenient one.
[JsonErrorBodies] answers every response with a status of 400 or more as JSON, whatever the request negotiated. Successes are unchanged. The attribute is in Hardened.Requests.Abstract.Attributes. Put it on the module that declares the routes, which is TodosLibrary in the template:
using DependencyModules.Runtime.Interfaces;
using Hardened.Requests.Abstract.Attributes;
using Hardened.Shared.Runtime.Attributes;
using Hardened.Web.Runtime.Attributes;
using Hardened.Web.Runtime.DependencyInjection;
using Hardened.Web.Runtime.OpenApi;
using Microsoft.Extensions.DependencyInjection;
using System.Text.Json.Serialization.Metadata;
namespace Todos;
[HardenedModule]
[HardenedWebModule]
[BasePath("/todos")]
[Server("http://localhost:5080", "Local")]
[Enable<OpenApiDocumentPublishing>]
[JsonErrorBodies]
public partial class TodosLibrary : IServiceCollectionConfiguration
{
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<IJsonTypeInfoResolver>(TodosJsonContext.Default);
}
}With the attribute, the 404 is JSON:
GET /todos/99
Accept: text/csv
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{"resource":"todo","detail":"No todo has id 99.","type":"urn:hardened:problem:not-found","title":"Not Found","status":404}The success is unchanged:
GET /todos/1
Accept: text/csv
HTTP/1.1 200 OK
Content-Type: text/csv
id,title,done
1,Read the generated code,TrueOn the module that declares the routes, the attribute changes the responses and the OpenAPI document. On the application module in the host project, it changes the responses but not the document. A contract asks for JSON error bodies with x-hardened-error-bodies: json at its root, as Generating from OpenAPI describes. The 406 body is JSON with or without the attribute.
In the OpenAPI document, the error responses of an operation list these media types:
| The handler | Its error responses list |
|---|---|
Returns string, byte[] or Stream | application/json alone |
| Streams its response | application/json alone |
Has [Output<T>] | application/json alone |
Is under [JsonErrorBodies] | application/json alone |
| Any other handler | Its declared media types, in order, then application/json when it is not one of them |
The document lists media types this way for the error statuses that the handler declares and for the ones that the pipeline adds, such as the validation 400. Where the body at a status is a problem type, ErrorModel or RequestValidationError, application/problem+json takes the place of application/json. Without [JsonErrorBodies], the document lists the 404 of GET /todos/{id} like this:
"404": {
"description": "Not Found",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/NotFound"
}
},
"text/csv": {
"schema": {
"$ref": "#/components/schemas/NotFound"
}
}
}
}Choosing the content type per request
A handler sets Response.ContentType on its IExecutionContext parameter to choose the content type of one response. The interface is in Hardened.Requests.Abstract.Execution. The execution pipeline covers it.
using System.Text;
using Hardened.Requests.Abstract.Attributes;
using Hardened.Requests.Abstract.Execution;
using Hardened.Web.Runtime.Attributes;
namespace Todos;
public class ExportController
{
[Get("/export")]
[Produces("text/csv", "text/tab-separated-values")]
public async Task<string> Export(
ITodoStore store,
IExecutionContext context,
[FromQueryString] string format = "csv"
)
{
var separator = format == "tsv" ? "\t" : ",";
context.Response.ContentType = format == "tsv" ? "text/tab-separated-values" : "text/csv";
var text = new StringBuilder($"id{separator}title\n");
foreach (var todo in await store.All())
{
text.Append($"{todo.Id}{separator}{todo.Title}\n");
}
return text.ToString();
}
}GET /todos/export
HTTP/1.1 200 OK
Content-Type: text/csv
id,title
1,Read the generated code
2,Add an endpointThe value that the handler sets wins over Accept:
GET /todos/export?format=tsv
Accept: text/csv
HTTP/1.1 200 OK
Content-Type: text/tab-separated-values
id title
1 Read the generated code
2 Add an endpointDeclare every media type that the handler can set. The OpenAPI document lists the declared types for the operation. A handler that returns byte[] or Stream and declares two or more media types must set one. When a handler returns a model and sets a media type that no registered serializer writes, the response is a 500 with an empty body.
Writing a serializer
A serializer implements IResponseSerializer, from Hardened.Requests.Abstract.Serializer. [SingletonService] registers it. src/Todos/CsvResponseSerializer.cs writes the text/csv responses on this page:
using System.Text;
using DependencyModules.Runtime.Attributes;
using Hardened.Requests.Abstract.Attributes;
using Hardened.Requests.Abstract.Execution;
using Hardened.Requests.Abstract.Serializer;
[assembly: WritesContentType("text/csv")]
namespace Todos;
[SingletonService]
public class CsvResponseSerializer : IResponseSerializer
{
public bool IsDefaultSerializer => false;
public string ContentType => "text/csv";
public async Task SerializeResponse(IExecutionContext context)
{
context.Response.ContentType = ContentType;
IEnumerable<Todo> rows = context.Response.ResponseValue switch
{
Todo todo => [todo],
IEnumerable<Todo> todos => todos,
_ => [],
};
var csv = new StringBuilder("id,title,done\n");
foreach (var row in rows)
{
csv.Append($"{row.Id},{row.Title},{row.Done}\n");
}
await context.Response.Body.WriteAsync(
Encoding.UTF8.GetBytes(csv.ToString()),
context.CancellationToken
);
}
}| Member | Description |
|---|---|
ContentType | The media type it writes, and the key it is found by |
IsDefaultSerializer | Whether it writes for an operation that declares nothing, and for a Lenient request that matches nothing. The JSON serializer returns true |
CanProduce(string mediaType, IExecutionContext context) | Whether it can write this response as that media type. The default implementation compares mediaType with ContentType |
SerializeResponse(IExecutionContext context) | Writes context.Response.ResponseValue to context.Response.Body |
A serializer is found by its ContentType. It writes the responses of every operation that declares that media type. It also writes their error bodies, as Error bodies describes. [assembly: WritesContentType("text/csv")] stops HRDR012 for the operations that declare text/csv.
SerializeResponse sets context.Response.ContentType itself. Under Lenient, an operation that declares one media type calls its serializer directly. Nothing else sets the header for that response.
When two serializers are registered for one media type, the one registered last writes the response. A serializer for application/json that TodosLibrary registers, with [SingletonService] or in its ConfigureServices, does not replace the framework's. Registered in ConfigureServices on the host's Application, it does. JSON serialization covers replacing the JSON serializer.
Request bodies
The request's Content-Type selects the deserializer that reads a body parameter. [Produces] has no effect on request bodies. The JSON deserializer reads a body whose Content-Type is application/json, text/json or a +json type such as application/merge-patch+json, whatever its parameters. It also reads a body with no Content-Type.
A body whose Content-Type no deserializer reads answers 415. The Accept header names the types the service reads, and so does the body. A JSON body sent as text/plain gets this response:
POST /todos
Content-Type: text/plain
{"title":"Buy milk"}
HTTP/1.1 415 Unsupported Media Type
Accept: application/json
Content-Type: application/problem+json
{"detail":"This route does not read text/plain. It reads application/json.","type":"urn:hardened:problem:unsupported-media-type","title":"Unsupported Media Type","status":415}A browser sends a text/plain POST to another origin without a CORS preflight, so the 415 also keeps such a request from reaching a JSON handler. CORS covers preflights.
The MessagePack package adds a deserializer for application/x-msgpack, and the 415 lists it too. MessagePack covers it. A [FromForm] parameter is read as a form, and a body that is not a form answers 415. Forms and files covers it. A body parameter declared as byte[] or Stream receives the body as sent. Parameter binding covers it.
A deserializer implements IRequestDeserializer, from Hardened.Requests.Abstract.Serializer. [SingletonService] registers it.
| Member | Description |
|---|---|
CanProcessContext(IExecutionContext context) | Whether it reads this request's body |
DeserializeRequestBody<T>(IExecutionContext context) | Reads the body as T |
IsDefaultSerializer | Whether it reads a body that no deserializer claimed. The JSON deserializer returns true |
Order | When it is asked, relative to the others. The default is RequestDeserializerOrder.Normal |
Deserializers are asked in Order, lowest first. The first one whose CanProcessContext returns true reads the body. Among deserializers with the same Order, the one registered last is asked first. The values of RequestDeserializerOrder are:
| Value | Number | Used by |
|---|---|---|
Specialized | -100 | A deserializer for one content type. The MessagePack deserializer |
Normal | 0 | The JSON deserializer |
Deferred | 1000 | A deserializer asked after the others |
Limits
[ContentNegotiation(ContentNegotiationMode.Lenient)] on a module class fails the build with CS1503 in the generated <Module>.Module.g.cs: "cannot convert from 'int' to 'Hardened.Requests.Abstract.Serializer.ContentNegotiationMode'". Register the policy instead, as Answering without a 406 shows. [assembly: ContentNegotiation(ContentNegotiationMode.Lenient)] builds and has no effect.
Under Strict without [JsonErrorBodies], a client that asks a string handler for its declared text media type gets a 406 in place of the operation's 400, 404 or 500.
Next
| Page | Covers |
|---|---|
| JSON serialization | The JSON serializer and its options |
| MessagePack | MessagePack beside JSON |
| Streaming responses | NDJSON and server-sent events |
| Views | Rendering HTML with a view |
| Response caching | One cache entry per representation |