Skip to content

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.

csharp
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();
}
http
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}]
http
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,False

CsvResponseSerializer 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 onWritten as
The method[Produces("text/csv")]
Its class[Produces("text/csv")] on the class
The handler's assembly[assembly: Produces("text/csv")]
The applicationservices.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 returnsIt declaresThe response
A modelNothingJSON
A modelOne or more media typesWritten by the serializer for the media type that the request gets
stringNothingWritten by the JSON serializer as a JSON string, such as "Hello"
stringOne or more media typesThe string unchanged, under the media type that the request gets
byte[] or StreamOne media type (HRDR011 without one)The bytes unchanged, under that media type
byte[] or StreamTwo or more media typesThe bytes unchanged, under the content type that the handler sets, or application/octet-stream
IAsyncEnumerable<T>AnythingFramed 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:

csharp
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:

text
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 operationAccept is readA request that names none of its media types
Declares nothingNoAnswered with JSON
Returns string, byte[] or Stream, and declares one media typeNoAnswered with that media type
Returns byte[] or Stream, and declares two or moreNoAnswered with the content type the handler sets, or application/octet-stream
Returns a model and declares one media typeYes406
Returns a model or string, and declares two or moreYes406

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")]:

AcceptAnswered with
Absent, empty or */*application/json, the first declared type
text/plain or TEXT/PLAINtext/plain
text/*text/plain
application/*application/json
text/plain;q=0.5, application/json;q=0.9text/plain, the first entry
image/png, text/plaintext/plain, the first entry it produces
image/png406

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:

http
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:

ModeA request that names none of the declared media types
Strict406, with a body naming what the operation produces. The default
LenientThe 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:

csharp
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:

http
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:

text
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:

text
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:

TypeNamespace
NotFoundHardened.Web.Runtime.Responses
RequestValidationErrorHardened.Requests.Runtime.Validation
ErrorModelHardened.Requests.Abstract.Errors

The getTodo operation declares the same two media types as listTodos:

csharp
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:

http
GET /todos/99
Accept: text/csv

HTTP/1.1 404 Not Found
Content-Type: text/csv

id,title,done

A 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:

csharp
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:

http
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:

http
GET /todos/1
Accept: text/csv

HTTP/1.1 200 OK
Content-Type: text/csv

id,title,done
1,Read the generated code,True

On 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 handlerIts error responses list
Returns string, byte[] or Streamapplication/json alone
Streams its responseapplication/json alone
Has [Output<T>]application/json alone
Is under [JsonErrorBodies]application/json alone
Any other handlerIts 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:

json
"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.

csharp
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();
    }
}
http
GET /todos/export

HTTP/1.1 200 OK
Content-Type: text/csv

id,title
1,Read the generated code
2,Add an endpoint

The value that the handler sets wins over Accept:

http
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 endpoint

Declare 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:

csharp
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
        );
    }
}
MemberDescription
ContentTypeThe media type it writes, and the key it is found by
IsDefaultSerializerWhether 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:

http
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.

MemberDescription
CanProcessContext(IExecutionContext context)Whether it reads this request's body
DeserializeRequestBody<T>(IExecutionContext context)Reads the body as T
IsDefaultSerializerWhether it reads a body that no deserializer claimed. The JSON deserializer returns true
OrderWhen 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:

ValueNumberUsed by
Specialized-100A deserializer for one content type. The MessagePack deserializer
Normal0The JSON deserializer
Deferred1000A 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 ​

PageCovers
JSON serializationThe JSON serializer and its options
MessagePackMessagePack beside JSON
Streaming responsesNDJSON and server-sent events
ViewsRendering HTML with a view
Response cachingOne cache entry per representation

Released under the MIT License.