#
Formatting Response Data in your API
#
Overview
This plan guides a developer through modern, practical ways to implement and demonstrate response formatting in an ASP.NET Core 10 Web API (controller-based and optional minimal APIs). You’ll:
- Start with the default JSON behavior and explicit string/text responses.
- Enable stricter content negotiation (honor
Acceptand return 406 when unsupported). - Add extra formatters (XML) only if needed.
- Demonstrate route-based
.{format}URLs vs.Acceptnegotiation. - Document responses for OpenAPI/Swagger.
- Show standardized error responses using
ProblemDetails. - Optionally add a simple custom formatter (CSV) to illustrate extensibility.
- Verify behavior with
curl, browser/Postman, and Swagger UI.
All steps are incremental and reversible. Use the smallest possible changes per step and verify as you go.
#
Detailed Implementation Steps
#
Step 1 Prerequisites and baseline
- Ensure your API builds and runs.
- Confirm you have a simple controller returning data, e.g.,
AlbumController.Get()returningActionResult<List<AlbumApiModel>>. - In
Program.cs(Chinook.API), you likely have:
builder.Services.AddControllers();
app.MapControllers();
Why: Establish the baseline behavior — JSON by default via System.Text.Json.
#
Step 2 Return strongly-typed JSON with explicit OpenAPI metadata
- Update one GET action to use
ActionResult<T>and add per-status metadata.
Example (controller):
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<AlbumApiModel>))]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<ActionResult<List<AlbumApiModel>>> Get()
{
var albums = await _chinookSupervisor.GetAllAlbum();
return Ok(albums);
}
Why: Clear contract for clients; accurate Swagger/OpenAPI generation.
#
Step 3 Explicit plain text responses (health/version/about)
- Add one endpoint that returns text and explicitly sets
text/plain.
Controller example:
[HttpGet("version")]
[Produces("text/plain")]
public IActionResult Version() => Content("1.0.0", "text/plain");
Why: Demonstrates non-JSON content and explicit content type.
#
Step 4 Enable stricter content negotiation (optional, recommended for demos)
- Honor browser
Acceptand return 406 for unsupported media types.
Program.cs:
builder.Services.AddControllers(options =>
{
options.RespectBrowserAcceptHeader = true; // honor Accept from browsers and clients
options.ReturnHttpNotAcceptable = true; // 406 if no formatter matches
});
Why: Makes the negotiation behavior visible and testable.
#
Step 5 Add an extra formatter (XML) only if needed
- If you want to show multiple formats, add XML output support.
Program.cs (extend the registration above):
builder.Services.AddControllers(options =>
{
options.RespectBrowserAcceptHeader = true;
options.ReturnHttpNotAcceptable = true;
})
.AddXmlSerializerFormatters()
.AddJsonOptions(o =>
{
// optional JSON tuning; keeps example realistic
o.JsonSerializerOptions.DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull;
});
Why: Demonstrates adding a secondary format without changing endpoints.
#
Step 6 Demonstrate Accept-driven negotiation
- Use the existing GET endpoint; no code changes. Clients control the format via
Accept:Accept: application/json→ JSONAccept: application/xml→ XML (since you added XML)Accept: text/csv→ 406 (no CSV formatter yet) becauseReturnHttpNotAcceptable = true
Why: Shows the default, standards-based negotiation path.
#
Step 7 Demonstrate route-based format with [FormatFilter] (legacy/optional)
- If you want URL suffixes like
/api/albums/5.json:
Controller setup:
[ApiController]
[Route("api/[controller]")]
[FormatFilter]
public class AlbumsController : ControllerBase
{
[HttpGet("{id}.{format?}")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AlbumApiModel))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(int id)
{
var album = await _chinookSupervisor.GetAlbum(id);
return album is null ? NotFound() : Ok(album);
}
}
- With XML formatter enabled, the following URLs work (negotiation fallback still applies):
/api/albums/5→ default formatter (typically JSON)/api/albums/5.json→ JSON/api/albums/5.xml→ XML
Why: Demonstrates an alternative for clients that prefer URL suffixes.
#
Step 8 Standardize error responses with ProblemDetails
- Add one example action that returns a standardized error.
Example:
[HttpGet("fail-example")]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public IActionResult FailExample()
{
return Problem(title: "Bad request", detail: "Example failure", statusCode: 400);
}
- With
[ApiController]enabled, invalid model states automatically returnValidationProblemDetails(no extra code).
Why: Shows modern, interoperable error payloads.
#
Step 9 Optional: Implement a simple custom CSV output formatter
- Create a tiny formatter for demo purposes (e.g., for a flat DTO). This is optional but great for workshops.
Skeleton steps:
- Create a
CsvOutputFormatter : TextOutputFormatter. - Support
text/csvmedia type and UTF-8 encoding. - Implement
CanWriteTypeandWriteResponseBodyAsyncto serialize a list of DTOs. - Register it:
builder.Services.AddControllers(options =>
{
options.RespectBrowserAcceptHeader = true;
options.ReturnHttpNotAcceptable = true;
options.OutputFormatters.Add(new CsvOutputFormatter());
});
Why: Demonstrates extensibility of the pipeline beyond built-ins.
#
Step 10 File and stream responses (bonus)
- Add an endpoint returning a file/bytes/stream with explicit content type.
Example:
[HttpGet("sample.txt")]
[Produces("text/plain")]
public IActionResult GetSampleFile()
{
var bytes = System.Text.Encoding.UTF8.GetBytes("Hello file\n");
return File(bytes, "text/plain", "sample.txt");
}
Why: Shows content-type control outside of object serialization.
#
Follow-up: Verification Checklist and Commands
Use curl (or Postman) to verify each behavior. Replace https://localhost:5001 with your dev URL.
- Baseline JSON (status 200,
application/json):
curl -i https://localhost:5001/api/albums
- Explicit text response (status 200,
text/plain):
curl -i https://localhost:5001/api/albums/version
- Accept negotiation — JSON (200):
curl -i -H "Accept: application/json" https://localhost:5001/api/albums
- Accept negotiation — XML (200 if XML formatter added):
curl -i -H "Accept: application/xml" https://localhost:5001/api/albums
- Unsupported Accept (406 when
ReturnHttpNotAcceptable = true):
curl -i -H "Accept: text/csv" https://localhost:5001/api/albums
- Route-based format filter (if enabled):
curl -i https://localhost:5001/api/albums/5.json
curl -i https://localhost:5001/api/albums/5.xml
- ProblemDetails error:
curl -i https://localhost:5001/api/albums/fail-example
Expect application/problem+json with a JSON body containing type, title, status, etc.
- File response:
curl -i https://localhost:5001/api/albums/sample.txt
Expect Content-Type: text/plain and a Content-Disposition filename.
- Swagger/OpenAPI checks:
- Run the app and open Swagger UI.
- Confirm response schemas, content types, and status codes match your attributes and endpoint metadata.
- If you added XML, confirm both
application/jsonandapplication/xmlare shown for responses.
- Optional CSV formatter (if implemented):
curl -i -H "Accept: text/csv" https://localhost:5001/api/albums
Expect 200 OK and Content-Type: text/csv with CSV content.
#
Tips and Good Practices
- Add only the formatters you will support; let negotiation pick the best match.
- Use
ProducesResponseType(controllers) or.Produces<T>()(minimal) for accurate OpenAPI. - Prefer
ProblemDetailsfor error payloads. - Be explicit with non-JSON content types (text, files) to avoid ambiguity.
- Keep route
.{format}only when audiences benefit from URL-based formats; otherwise preferAcceptnegotiation. - Consider integration tests that assert
Content-Typeand 406 behavior for key endpoints.