Skip to content

Commit 38ea78e

Browse files
Merge pull request JasperFx#2437 from JasperFx/2406-content-negotiation
Add response content negotiation with [Writes] and ConnegMode
2 parents e51555d + 701e9d6 commit 38ea78e

11 files changed

Lines changed: 652 additions & 0 deletions

File tree

docs/.vitepress/config.mts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ const config: UserConfig<DefaultTheme.Config> = {
222222
{text: 'Integration with ASP.Net Core', link: '/guide/http/integration'},
223223
{text: 'Endpoints', link: '/guide/http/endpoints'},
224224
{text: 'Json', link: '/guide/http/json'},
225+
{text: 'Content Negotiation', link: '/guide/http/conneg'},
225226
{text: 'Routing', link: '/guide/http/routing'},
226227
{text: 'Authentication and Authorization', link: '/guide/http/security'},
227228
{text: 'Antiforgery / CSRF Protection', link: '/guide/http/antiforgery'},

docs/guide/http/conneg.md

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
# Content Negotiation
2+
3+
Wolverine supports content negotiation for HTTP endpoints, allowing you to serve different response formats based on the client's `Accept` header. This is done through the `[Writes]` attribute and the `WriteResponse` naming convention.
4+
5+
## Response Content Negotiation
6+
7+
To add content negotiation support for responses, add methods with the `[Writes("content-type")]` attribute to your endpoint class. These methods will be called instead of the default JSON serialization when the client's `Accept` header matches:
8+
9+
<!-- snippet: sample_conneg_write_response -->
10+
<a id='snippet-sample_conneg_write_response'></a>
11+
```cs
12+
/// <summary>
13+
/// Demonstrates content negotiation with [Writes] attribute.
14+
/// Multiple WriteResponse methods handle different content types.
15+
/// </summary>
16+
public static class ConnegWriteEndpoints
17+
{
18+
[WolverineGet("/conneg/write")]
19+
public static ConnegItem GetItem()
20+
{
21+
return new ConnegItem("Widget", 42);
22+
}
23+
24+
/// <summary>
25+
/// Writes the response as plain text when Accept: text/plain
26+
/// </summary>
27+
[Writes("text/plain")]
28+
public static Task WriteResponse(HttpContext context, ConnegItem response)
29+
{
30+
context.Response.ContentType = "text/plain";
31+
return context.Response.WriteAsync($"{response.Name}: {response.Value}");
32+
}
33+
34+
/// <summary>
35+
/// Writes the response as CSV when Accept: text/csv
36+
/// </summary>
37+
[Writes("text/csv")]
38+
public static Task WriteResponseCsv(HttpContext context, ConnegItem response)
39+
{
40+
context.Response.ContentType = "text/csv";
41+
return context.Response.WriteAsync($"Name,Value\n{response.Name},{response.Value}");
42+
}
43+
}
44+
```
45+
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Http/WolverineWebApi/ConnegEndpoints.cs#L11-L52' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_conneg_write_response' title='Start of snippet'>anchor</a></sup>
46+
<!-- endSnippet -->
47+
48+
Key points:
49+
- Methods must have the `[Writes("content-type")]` attribute to specify which content type they handle
50+
- The method name can be anything — it's the `[Writes]` attribute that matters
51+
- Methods receive the `HttpContext` and the response resource as parameters
52+
- Methods can be sync (`void`) or async (`Task`)
53+
- Multiple `[Writes]` methods can coexist on the same class for different content types
54+
55+
## Loose vs Strict Mode
56+
57+
By default, content negotiation uses **Loose** mode: if no `[Writes]` method matches the client's `Accept` header, Wolverine falls back to JSON serialization. This ensures clients always get a response.
58+
59+
### Strict Mode
60+
61+
Use `[StrictConneg]` to enable **Strict** mode. In strict mode, if no `[Writes]` method matches, Wolverine returns HTTP 406 Not Acceptable:
62+
63+
<!-- snippet: sample_conneg_strict -->
64+
<a id='snippet-sample_conneg_strict'></a>
65+
```cs
66+
/// <summary>
67+
/// Strict content negotiation — returns 406 when Accept header doesn't match
68+
/// </summary>
69+
[StrictConneg]
70+
public static class StrictConnegEndpoints
71+
{
72+
[WolverineGet("/conneg/strict")]
73+
public static ConnegItem GetStrictItem()
74+
{
75+
return new ConnegItem("StrictWidget", 99);
76+
}
77+
78+
[Writes("text/plain")]
79+
public static Task WriteResponse(HttpContext context, ConnegItem response)
80+
{
81+
context.Response.ContentType = "text/plain";
82+
return context.Response.WriteAsync($"{response.Name}: {response.Value}");
83+
}
84+
}
85+
```
86+
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Http/WolverineWebApi/ConnegEndpoints.cs#L54-L76' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_conneg_strict' title='Start of snippet'>anchor</a></sup>
87+
<!-- endSnippet -->
88+
89+
### Global Configuration
90+
91+
You can set the default `ConnegMode` for all endpoints using a policy:
92+
93+
```csharp
94+
app.MapWolverineEndpoints(opts =>
95+
{
96+
// Set strict mode globally
97+
opts.ConfigureEndpoints(chain =>
98+
{
99+
chain.ConnegMode = ConnegMode.Strict;
100+
});
101+
});
102+
```
103+
104+
Per-endpoint configuration (via `[StrictConneg]` attribute) overrides the global setting.
105+
106+
## Loose Mode Fallback
107+
108+
In the default **Loose** mode, when no `[Writes]` method matches the `Accept` header, the response falls back to JSON serialization — exactly the same as an endpoint without any content negotiation:
109+
110+
<!-- snippet: sample_conneg_loose_fallback -->
111+
<a id='snippet-sample_conneg_loose_fallback'></a>
112+
```cs
113+
/// <summary>
114+
/// Loose content negotiation (default) — falls back to JSON when no match
115+
/// </summary>
116+
public static class LooseConnegEndpoints
117+
{
118+
[WolverineGet("/conneg/loose")]
119+
public static ConnegItem GetLooseItem()
120+
{
121+
return new ConnegItem("LooseWidget", 77);
122+
}
123+
124+
[Writes("text/plain")]
125+
public static Task WriteResponse(HttpContext context, ConnegItem response)
126+
{
127+
context.Response.ContentType = "text/plain";
128+
return context.Response.WriteAsync($"{response.Name}: {response.Value}");
129+
}
130+
}
131+
```
132+
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Http/WolverineWebApi/ConnegEndpoints.cs#L78-L99' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_conneg_loose_fallback' title='Start of snippet'>anchor</a></sup>
133+
<!-- endSnippet -->
134+
135+
## How It Works
136+
137+
At code generation time, Wolverine generates branching code that checks the `Accept` header and dispatches to the correct writer:
138+
139+
```csharp
140+
// Generated code (simplified)
141+
var acceptHeader = httpContext.Request.Headers.Accept.ToString();
142+
143+
if (acceptHeader.Contains("text/plain"))
144+
{
145+
httpContext.Response.ContentType = "text/plain";
146+
await ConnegWriteEndpoints.WriteResponse(httpContext, item_response);
147+
}
148+
else if (acceptHeader.Contains("text/csv"))
149+
{
150+
httpContext.Response.ContentType = "text/csv";
151+
await ConnegWriteEndpoints.WriteResponseCsv(httpContext, item_response);
152+
}
153+
else
154+
{
155+
// Loose: fallback to JSON
156+
await WriteJsonAsync(httpContext, item_response);
157+
// Strict: httpContext.Response.StatusCode = 406;
158+
}
159+
```
160+
161+
This means there is **zero runtime reflection** — the content type dispatching is compiled at startup.
162+
163+
## Request Content Type Negotiation
164+
165+
For request-side content negotiation (dispatching based on `Content-Type` header), use the existing `[AcceptsContentType]` attribute to create separate endpoint methods for different request formats:
166+
167+
```csharp
168+
public static class RequestConnegEndpoints
169+
{
170+
[WolverinePost("/items"), AcceptsContentType("application/vnd.item.v1+json")]
171+
public static ItemCreated CreateV1(CreateItemV1 command)
172+
{
173+
return new ItemCreated(command.Name, "v1");
174+
}
175+
176+
[WolverinePost("/items"), AcceptsContentType("application/vnd.item.v2+json")]
177+
public static ItemCreated CreateV2(CreateItemV2 command)
178+
{
179+
return new ItemCreated(command.Name, "v2");
180+
}
181+
}
182+
```
183+
184+
This allows the same route and HTTP method to handle different request body formats.
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
using Alba;
2+
using Shouldly;
3+
using WolverineWebApi;
4+
using Xunit;
5+
using Xunit.Abstractions;
6+
7+
namespace Wolverine.Http.Tests;
8+
9+
public class ContentNegotiationTests : IntegrationContext
10+
{
11+
private readonly ITestOutputHelper _output;
12+
13+
public ContentNegotiationTests(AppFixture fixture, ITestOutputHelper output) : base(fixture)
14+
{
15+
_output = output;
16+
}
17+
18+
[Fact]
19+
public async Task writes_text_plain_when_accept_header_matches()
20+
{
21+
var result = await Scenario(x =>
22+
{
23+
x.Get.Url("/conneg/write");
24+
x.WithRequestHeader("Accept", "text/plain");
25+
x.StatusCodeShouldBeOk();
26+
});
27+
28+
var text = result.ReadAsText();
29+
text.ShouldBe("Widget: 42");
30+
}
31+
32+
[Fact]
33+
public async Task writes_csv_when_accept_header_matches()
34+
{
35+
var result = await Scenario(x =>
36+
{
37+
x.Get.Url("/conneg/write");
38+
x.WithRequestHeader("Accept", "text/csv");
39+
x.StatusCodeShouldBeOk();
40+
});
41+
42+
var text = result.ReadAsText();
43+
text.ShouldBe("Name,Value\nWidget,42");
44+
}
45+
46+
[Fact]
47+
public async Task falls_back_to_json_in_loose_mode()
48+
{
49+
var result = await Scenario(x =>
50+
{
51+
x.Get.Url("/conneg/write");
52+
x.WithRequestHeader("Accept", "application/json");
53+
x.StatusCodeShouldBeOk();
54+
});
55+
56+
var item = result.ReadAsJson<ConnegItem>();
57+
item.ShouldNotBeNull();
58+
item!.Name.ShouldBe("Widget");
59+
item.Value.ShouldBe(42);
60+
}
61+
62+
[Fact]
63+
public async Task falls_back_to_json_with_no_accept_header()
64+
{
65+
var result = await Scenario(x =>
66+
{
67+
x.Get.Url("/conneg/loose");
68+
// No Accept header — falls back to JSON in loose mode
69+
x.StatusCodeShouldBeOk();
70+
});
71+
72+
var item = result.ReadAsJson<ConnegItem>();
73+
item.ShouldNotBeNull();
74+
item!.Name.ShouldBe("LooseWidget");
75+
}
76+
77+
[Fact]
78+
public async Task strict_mode_returns_406_when_no_match()
79+
{
80+
await Scenario(x =>
81+
{
82+
x.Get.Url("/conneg/strict");
83+
x.WithRequestHeader("Accept", "application/xml");
84+
x.StatusCodeShouldBe(406);
85+
});
86+
}
87+
88+
[Fact]
89+
public async Task strict_mode_returns_ok_when_match()
90+
{
91+
var result = await Scenario(x =>
92+
{
93+
x.Get.Url("/conneg/strict");
94+
x.WithRequestHeader("Accept", "text/plain");
95+
x.StatusCodeShouldBeOk();
96+
});
97+
98+
var text = result.ReadAsText();
99+
text.ShouldBe("StrictWidget: 99");
100+
}
101+
102+
[Fact]
103+
public async Task loose_mode_text_plain_works()
104+
{
105+
var result = await Scenario(x =>
106+
{
107+
x.Get.Url("/conneg/loose");
108+
x.WithRequestHeader("Accept", "text/plain");
109+
x.StatusCodeShouldBeOk();
110+
});
111+
112+
var text = result.ReadAsText();
113+
text.ShouldBe("LooseWidget: 77");
114+
}
115+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
namespace Wolverine.Http.ContentNegotiation;
2+
3+
/// <summary>
4+
/// Controls how content negotiation behaves when no matching content type writer is found
5+
/// </summary>
6+
public enum ConnegMode
7+
{
8+
/// <summary>
9+
/// Default. Falls back to JSON serialization when no specific writer matches the Accept header.
10+
/// This is the most permissive mode and ensures clients always get a response.
11+
/// </summary>
12+
Loose,
13+
14+
/// <summary>
15+
/// Returns HTTP 406 Not Acceptable when no specific writer matches the Accept header.
16+
/// Use this mode to strictly enforce content negotiation.
17+
/// </summary>
18+
Strict
19+
}

0 commit comments

Comments
 (0)