WebApiContrib
Overview
WebApiContrib.Core is a community-driven collection of extensions for ASP.NET Core Web API that adds custom input/output formatters, content negotiation strategies, and utility middleware. The library provides formatters for CSV, BSON, MessagePack, Protocol Buffers, and plain text, enabling APIs to serve multiple content types through standard HTTP content negotiation (the Accept header). WebApiContrib.Core integrates with the ASP.NET Core MVC formatter pipeline, allowing controllers and minimal APIs to return data in the format requested by the client without changing action method signatures. It also includes additional filters, model binders, and helper utilities.
CSV Formatter Setup
Add CSV input/output formatting to an ASP.NET Core API.
using WebApiContrib.Core.Formatter.Csv;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers(options =>
{
options.RespectBrowserAcceptHeader = true;
options.ReturnHttpNotAcceptable = true;
})
.AddCsvSerializerFormatters(new CsvFormatterOptions
{
UseSingleLineHeaderInCsv = true,
CsvDelimiter = ",",
IncludeExcelDelimiterHeader = false,
Encoding = System.Text.Encoding.UTF8
});
var app = builder.Build();
app.MapControllers();
app.Run();
using Microsoft.AspNetCore.Mvc;
namespace MyApp.Controllers;
[ApiController]
[Route("api/[controller]")]
public class ReportsController : ControllerBase
{
private readonly IReportService _reportService;
public ReportsController(IReportService reportService)
{
_reportService = reportService;
}
// Returns JSON by default; returns CSV when Accept: text/csv
[HttpGet("sales")]
[Produces("application/json", "text/csv")]
public async Task<IActionResult> GetSalesReport(
[FromQuery] DateTime startDate,
[FromQuery] DateTime endDate)
{
var data = await _reportService.GetSalesDataAsync(startDate, endDate);
return Ok(data);
}
// Force CSV download with content disposition
[HttpGet("sales/download")]
[Produces("text/csv")]
public async Task<IActionResult> DownloadSalesReport(
[FromQuery] DateTime startDate,
[FromQuery] DateTime endDate)
{
var data = await _reportService.GetSalesDataAsync(startDate, endDate);
Response.Headers.Append(
"Content-Disposition",
$"attachment; filename=sales_{startDate:yyyyMMdd}_{endDate:yyyyMMdd}.csv");
return Ok(data);
}
}
public record SalesRecord(
string ProductName,
int Quantity,
decimal Revenue,
DateTime Date);
MessagePack Formatter
Add high-performance binary serialization with MessagePack.
using MessagePack;
using Microsoft.AspNetCore.Mvc.Formatters;
using Microsoft.Net.Http.Headers;
namespace MyApp.Formatters;
public class MessagePackOutputFormatter : OutputFormatter
{
private readonly MessagePackSerializerOptions _options;
public MessagePackOutputFormatter()
{
_options = MessagePackSerializerOptions.Standard
.WithCompression(MessagePackCompression.Lz4BlockArray);
SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/x-msgpack"));
SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/msgpack"));
}
protected override bool CanWriteType(Type? type) => type != null;
public override async Task WriteResponseBodyAsync(OutputFormatterWriteContext context)
{
var response = context.HttpContext.Response;
var bytes = MessagePackSerializer.Serialize(
context.ObjectType!,
context.Object,
_options);
response.ContentType = "application/x-msgpack";
response.ContentLength = bytes.Length;
await response.Body.WriteAsync(bytes);
}
}
public class MessagePackInputFormatter : InputFormatter
{
private readonly MessagePackSerializerOptions _options;
public MessagePackInputFormatter()
{
_options = MessagePackSerializerOptions.Standard
.WithCompression(MessagePackCompression.Lz4BlockArray);
SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/x-msgpack"));
SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/msgpack"));
}
public override async Task<InputFormatterResult> ReadRequestBodyAsync(
InputFormatterContext context)
{
var request = context.HttpContext.Request;
using var memoryStream = new MemoryStream();
await request.Body.CopyToAsync(memoryStream);
memoryStream.Position = 0;
var result = await MessagePackSerializer.DeserializeAsync(
context.ModelType,
memoryStream,
_options);
return await InputFormatterResult.SuccessAsync(result);
}
}
// Registration
builder.Services.AddControllers(options =>
{
options.OutputFormatters.Add(new MessagePackOutputFormatter());
options.InputFormatters.Add(new MessagePackInputFormatter());
options.RespectBrowserAcceptHeader = true;
options.ReturnHttpNotAcceptable = true;
});
Custom Content Negotiation
Configure content negotiation to support multiple formats per endpoint.
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Formatters;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers(options =>
{
// Return 406 Not Acceptable instead of defaulting to JSON
options.ReturnHttpNotAcceptable = true;
// Respect the Accept header from browsers
options.RespectBrowserAcceptHeader = true;
// Add custom formatters
options.OutputFormatters.Add(new MessagePackOutputFormatter());
options.OutputFormatters.Add(new XmlSerializerOutputFormatter());
// Remove text/plain formatter if not needed
options.OutputFormatters.RemoveType<StringOutputFormatter>();
// Configure format mapping for URL-based negotiation
options.FormatterMappings.SetMediaTypeMappingForFormat(
"csv", "text/csv");
options.FormatterMappings.SetMediaTypeMappingForFormat(
"xml", "application/xml");
options.FormatterMappings.SetMediaTypeMappingForFormat(
"msgpack", "application/x-msgpack");
})
.AddCsvSerializerFormatters()
.AddXmlSerializerFormatters();
var app = builder.Build();
app.MapControllers();
app.Run();
using Microsoft.AspNetCore.Mvc;
namespace MyApp.Controllers;
[ApiController]
[Route("api/[controller]")]
[FormatFilter] // Enables /api/products.csv, /api/products.xml
public class ProductsController : ControllerBase
{
private readonly IProductRepository _repo;
public ProductsController(IProductRepository repo) => _repo = repo;
// GET /api/products -> JSON (Accept: application/json)
// GET /api/products.csv -> CSV (format mapping)
// GET /api/products.xml -> XML (format mapping)
[HttpGet]
[HttpGet("{format?}")]
[Produces("application/json", "text/csv", "application/xml", "application/x-msgpack")]
public async Task<IActionResult> GetAll()
{
var products = await _repo.GetAllAsync();
return Ok(products);
}
[HttpPost]
[Consumes("application/json", "application/xml", "application/x-msgpack")]
public async Task<IActionResult> Create([FromBody] Product product)
{
await _repo.AddAsync(product);
return CreatedAtAction(nameof(GetAll), new { id = product.Id }, product);
}
}
Protobuf Formatter
Add Protocol Buffers support for high-efficiency binary serialization.
using Google.Protobuf;
using Microsoft.AspNetCore.Mvc.Formatters;
using Microsoft.Net.Http.Headers;
namespace MyApp.Formatters;
public class ProtobufOutputFormatter : OutputFormatter
{
public ProtobufOutputFormatter()
{
SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/x-protobuf"));
}
protected override bool CanWriteType(Type? type)
{
return type != null && typeof(IMessage).IsAssignableFrom(type);
}
public override async Task WriteResponseBodyAsync(OutputFormatterWriteContext context)
{
if (context.Object is IMessage message)
{
var bytes = message.ToByteArray();
context.HttpContext.Response.ContentType = "application/x-protobuf";
context.HttpContext.Response.ContentLength = bytes.Length;
await context.HttpContext.Response.Body.WriteAsync(bytes);
}
}
}
public class ProtobufInputFormatter : InputFormatter
{
public ProtobufInputFormatter()
{
SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/x-protobuf"));
}
protected override bool CanReadType(Type type)
{
return typeof(IMessage).IsAssignableFrom(type);
}
public override async Task<InputFormatterResult> ReadRequestBodyAsync(
InputFormatterContext context)
{
using var memoryStream = new MemoryStream();
await context.HttpContext.Request.Body.CopyToAsync(memoryStream);
var messageDescriptor = (MessageDescriptor)context.ModelType
.GetProperty("Descriptor", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static)!
.GetValue(null)!;
var message = messageDescriptor.Parser.ParseFrom(memoryStream.ToArray());
return await InputFormatterResult.SuccessAsync(message);
}
}
WebApiContrib.Core vs Other Formatting Approaches
| Feature | WebApiContrib.Core | Custom Formatters | Content-Type Middleware | Separate Endpoints |
|---|---|---|---|---|
| CSV | Built-in formatter | Manual implementation | Not applicable | Manual serialization |
| BSON | Built-in formatter | Manual implementation | Not applicable | Manual serialization |
| Content negotiation | Standard Accept header | Standard Accept header | Custom header parsing | URL-based (/csv, /json) |
| Integration | MVC formatter pipeline | MVC formatter pipeline | Middleware pipeline | Per-endpoint |
| Configuration | One-liner .Add*() | Register in options | Custom middleware | Per-endpoint code |
| Format mapping | [FormatFilter] + URL | [FormatFilter] + URL | Not built-in | URL path |
| Complexity | Minimal | Moderate | High | High (duplication) |
Best Practices
-
Set
options.ReturnHttpNotAcceptable = trueinAddControllers()so that clients requesting unsupported formats (e.g.,Accept: application/yamlwhen only JSON and CSV are configured) receive a406 Not Acceptableresponse instead of silently falling back to JSON, making content negotiation failures explicit. -
Set
options.RespectBrowserAcceptHeader = truewhen you want browsers to receive non-JSON responses based on theirAcceptheader, because ASP.NET Core ignores the browser'sAccept: text/htmlby default and always returns JSON, which is correct for API clients but confusing for browser-based testing. -
Use
[Produces("application/json", "text/csv")]on controller actions to document which content types each endpoint supports, so that OpenAPI/Swagger generation includes the correct response content types and clients know whichAcceptvalues are valid. -
Use
[FormatFilter]withFormatterMappings.SetMediaTypeMappingForFormat()to enable URL-based format selection (e.g.,/api/products.csv,/api/products.xml) as an alternative to theAcceptheader, because some clients (browsers, curl without headers) cannot easily set request headers. -
Register formatters in the correct order in
options.OutputFormatters(JSON first, then specialized formats) because the first formatter that can handle the request'sAcceptheader wins, and placing a binary formatter first would cause it to be selected when the client sendsAccept: */*. -
Use
[Consumes("application/json", "application/xml")]on POST/PUT actions to restrict which input formats are accepted, returning415 Unsupported Media Typefor unrecognized content types rather than attempting to deserialize arbitrary payloads. -
Set
CsvFormatterOptions.CsvDelimiterexplicitly (comma or semicolon) andIncludeExcelDelimiterHeaderbased on the target audience, because European locales use semicolons as CSV delimiters and Excel requires asep=header to parse the file correctly. -
Implement both
InputFormatterandOutputFormatterfor binary formats (MessagePack, Protobuf, BSON) to support both reading request bodies and writing response bodies, rather than only implementing output formatting which leaves POST/PUT endpoints unable to accept the same format. -
Add
Content-Disposition: attachment; filename=report.csvheaders on endpoints intended for file download rather than inline display, so that browsers prompt the user to save the file instead of rendering the CSV text in the browser window. -
Write integration tests that send requests with different
Acceptheaders and assert on both the responseContent-Typeand the deserialized body shape, ensuring that content negotiation produces correct output for each format and that CSV column headers match the DTO property names.