Update: I have uploaded a small test project to github: link
I am creating a small web service with .Net Core 2, and would like to give the ability to clients to specify if they need navigational info in the response or not. The web api should only support xml and json, but it would be nice if clients could use Accept: application/xml+hateoas or Accept: application/json+hateoas in their request.
I tried setting up my AddMvc method like this:
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc(options =>
{
options.RespectBrowserAcceptHeader = true;
options.ReturnHttpNotAcceptable = true;
options.FormatterMappings.SetMediaTypeMappingForFormat(
"xml", MediaTypeHeaderValue.Parse("application/xml"));
options.FormatterMappings.SetMediaTypeMappingForFormat(
"json", MediaTypeHeaderValue.Parse("application/json"));
options.FormatterMappings.SetMediaTypeMappingForFormat(
"xml+hateoas", MediaTypeHeaderValue.Parse("application/xml"));
options.FormatterMappings.SetMediaTypeMappingForFormat(
"json+hateoas", MediaTypeHeaderValue.Parse("application/json"));
})
.AddJsonOptions(options => {
// Force Camel Case to JSON
options.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver();
})
.AddXmlSerializerFormatters()
.AddXmlDataContractSerializerFormatters()
;
And I am using the accept header in my controller methods to differentiate between normal xml/json response, and hateoas-like response, like this:
[HttpGet]
[Route("GetAllSomething")]
public async Task<IActionResult> GetAllSomething([FromHeader(Name = "Accept")]string accept)
{
...
bool generateLinks = !string.IsNullOrWhiteSpace(accept) && accept.ToLower().EndsWith("hateoas");
...
if (generateLinks)
{
AddNavigationLink(Url.Link("GetSomethingById", new { Something.Id }), "self", "GET");
}
...
}
So, in short, I do not want to create custom formatters, because the only "custom" thing is to either include or exclude navigational links in my response, but the response itself should be xml or json based on the Accept header value.
My model class looks like this (with mainly strings and basic values in it):
[DataContract]
public class SomethingResponse
{
[DataMember]
public int Id { get; private set; }
When calling my service from Fiddler, I got the following results for the different Accept values:
Could someone tell me which setting is wrong?
Mapping of format to Media Type (SetMediaTypeMappingForFormat
calls) works not as you expect. This mapping does not use Accept
header in the request. It reads requested format from parameter named format
in route data or URL query string. You should also mark your controller or action with FormatFilter
attribute. There are several good articles about response formatting based on FormatFilter
attribute, check here and here.
To fix your current format mappings, you should do the following:
Rename format so that it does not contain plus sign. Special +
character will give you troubles when passed in URL. It's better to replace it with -
:
options.FormatterMappings.SetMediaTypeMappingForFormat(
"xml-hateoas", MediaTypeHeaderValue.Parse("application/xml"));
options.FormatterMappings.SetMediaTypeMappingForFormat(
"json-hateoas", MediaTypeHeaderValue.Parse("application/json"));
Add format
parameter to the route:
[Route("GetAllSomething/{format}")]
Format used for format mapping can't be extracted from Accept
header, so you will pass it in the URL. Since you need to know the format for the logic in your controller, you could map above format
from the route to action parameter to avoid duplication in Accept
header:
public async Task<IActionResult> GetAllSomething(string format)
Now you don't need to pass required format in Accept
header because the format will be mapped from request URL.
Mark controller or action with FormatFilter
attribute.
The final action:
[HttpGet]
[Route("GetAllSomething/{format}")]
[FormatFilter]
public async Task<IActionResult> GetAllSomething(string format)
{
bool generateLinks = !string.IsNullOrWhiteSpace(format) && format.ToLower().EndsWith("hateoas");
// ...
return await Task.FromResult(Ok(new SomeModel { SomeProperty = "Test" }));
}
Now if you request URL /GetAllSomething/xml-hateoas
(even with missing Accept
header), FormatFilter
will map format
value of xml-hateoas
to application/xml
and XML formatter will be used for the response. Requested format will also be accessible in format
parameter of GetAllSomething
action.
Sample Project with formatter mappings on GitHub
Besides formatter mappings, you could achieve your goal by adding new supported media types to existing Media Types Formatters. Supported media types are stored in OutputFormatter.SupportedMediaTypes
collection and are filled in constructor of concrete output formatter, e.g. XmlSerializerOutputFormatter
. You could create the formatter instance by yourself (instead of using AddXmlSerializerFormatters
extension call) and add required media types to SupportedMediaTypes
collection. To adjust JSON formatter, which is added by default, just find its instance in options.OutputFormatters
:
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc(options =>
{
options.RespectBrowserAcceptHeader = true;
options.ReturnHttpNotAcceptable = true;
options.InputFormatters.Add(new XmlSerializerInputFormatter());
var xmlOutputFormatter = new XmlSerializerOutputFormatter();
xmlOutputFormatter.SupportedMediaTypes.Add("application/xml+hateoas");
options.OutputFormatters.Add(xmlOutputFormatter);
var jsonOutputFormatter = options.OutputFormatters.OfType<JsonOutputFormatter>().FirstOrDefault();
jsonOutputFormatter?.SupportedMediaTypes.Add("application/json+hateoas");
})
.AddJsonOptions(options => {
// Force Camel Case to JSON
options.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver();
})
.AddXmlDataContractSerializerFormatters();
}
In this case GetAllSomething
should be the same as in your original question. You should also pass required format in Accept
header, e.g. Accept: application/xml+hateoas
.