« Asp.net core 9 web api » : différence entre les versions
Aucun résumé des modifications |
|||
(43 versions intermédiaires par le même utilisateur non affichées) | |||
Ligne 1 : | Ligne 1 : | ||
[[Category:.NET | [[Category:ASP.NET]] | ||
= Links = | = Links = | ||
*[https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/overview?view=aspnetcore-9.0 Minimal APIs] | * [https://learn.microsoft.com/en-us/aspnet/core/release-notes/aspnetcore-9.0?view=aspnetcore-9.0 What's new in ASP.NET Core 9.0] | ||
* [https://learn.microsoft.com/en-us/aspnet/core/fundamentals/apis?view=aspnetcore-9.0 Microsoft documentation] | |||
* [https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/overview?view=aspnetcore-9.0 Minimal APIs] | |||
= New projet = | = New projet = | ||
Ligne 7 : | Ligne 9 : | ||
dotnet new webapi -o [project-name] --no-https | dotnet new webapi -o [project-name] --no-https | ||
</kode> | </kode> | ||
= Debug launch profiles / launchSettings = | = Debug launch profiles / launchSettings = | ||
Ligne 36 : | Ligne 27 : | ||
}, | }, | ||
"$schema": "https://json.schemastore.org/launchsettings.json" | "$schema": "https://json.schemastore.org/launchsettings.json" | ||
} | } | ||
</filebox> | </filebox> | ||
Ligne 83 : | Ligne 45 : | ||
<kode lang='cs'> | <kode lang='cs'> | ||
[HttpGet("{id:int}")] | [HttpGet("{id:int}")] | ||
[ProducesResponseType | [ProducesResponseType(StatusCodes.Status200OK)] | ||
[ProducesResponseType(StatusCodes.Status404NotFound)] | [ProducesResponseType(StatusCodes.Status404NotFound)] | ||
public async Task< | public async Task<ActionResult<ItemResponse>> GetByIdAsync(int id, CancellationToken cancellationToken) | ||
{ | { | ||
var item = await context.Items | var item = await context.Items | ||
Ligne 98 : | Ligne 60 : | ||
<kode lang='cs'> | <kode lang='cs'> | ||
[HttpGet] | [HttpGet] | ||
[ProducesResponseType | [ProducesResponseType(StatusCodes.Status200OK)] | ||
[ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)] | [ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)] | ||
public async Task< | public async Task<ActionResult<ItemResponse[]>> GetAsync([FromQuery] GetItemsRequest getItemsRequest, CancellationToken cancellationToken) | ||
{ | { | ||
var predicate = PredicateBuilder.New<Item>(true); | var predicate = PredicateBuilder.New<Item>(true); | ||
Ligne 121 : | Ligne 83 : | ||
<kode lang='csharp'> | <kode lang='csharp'> | ||
[HttpPost] | [HttpPost] | ||
[ProducesResponseType | [ProducesResponseType(StatusCodes.Status201Created)] | ||
[ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)] | [ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)] | ||
public async Task< | public async Task<ActionResult<ItemResponse>> CreateAsync(CreateUpdateItemQuery query, CancellationToken cancellationToken) | ||
{ | { | ||
var item = this.mapper.Map<Item>(query); | var item = this.mapper.Map<Item>(query); | ||
Ligne 131 : | Ligne 93 : | ||
var response = this.mapper.Map<ItemResponse>(item); | var response = this.mapper.Map<ItemResponse>(item); | ||
var actionName = nameof(GetByIdAsync); // to handle Async suffix see the warning box below | |||
var actionName = nameof( | |||
var routeValues = new { id = item.Id }; // path and query parameters | var routeValues = new { id = item.Id }; // path and query parameters | ||
return CreatedAtAction(actionName | return CreatedAtAction(actionName, routeValues, response); | ||
return CreatedAtRoute( | return CreatedAtRoute(new { response.Id }, response); // Location header seems wrong: /items?id=5 | ||
} | } | ||
</kode> | </kode> | ||
Ligne 262 : | Ligne 209 : | ||
== Headers == | == Headers == | ||
<kode lang='cs'> | <kode lang='cs'> | ||
public IActionResult Get([FromHeader(Name = "Your-Specific-Header")] string headerValue) { } | |||
if (Request.Headers.TryGetValue("Your-Specific-Header", out var headerValue)) | |||
{ | |||
var specificHeaderValue = headerValue.ToString(); | |||
} | |||
Response.Headers.Append("Key", "Value"); | Response.Headers.Append("Key", "Value"); | ||
</kode> | </kode> | ||
== [https://learn.microsoft.com/en-us/aspnet/core/web-api/advanced/conventions?view=aspnetcore-9.0 Web API conventions] == | |||
By default, the web API returns an HTTP code 200. It is possible to define a custom web API convention: | |||
<filebox fn='Progam.cs'> | |||
[assembly: ApiConventionType(typeof(ApiConventions))] | |||
</filebox> | |||
<filebox fn='ApiConventions.cs' collapsed> | |||
public static class ApiConventions | |||
{ | |||
[ProducesResponseType(StatusCodes.Status200OK)] | |||
[ProducesResponseType<uint>(StatusCodes.Status404NotFound)] | |||
public static void Get(uint id) { } | |||
[ProducesResponseType(StatusCodes.Status200OK)] | |||
[ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)] | |||
public static void Get([ApiConventionNameMatch(ApiConventionNameMatchBehavior.Suffix)] object request) { } | |||
[ProducesResponseType(StatusCodes.Status201Created)] | |||
[ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)] | |||
public static void Create([ApiConventionNameMatch(ApiConventionNameMatchBehavior.Suffix)] object request) { } | |||
[ProducesResponseType(StatusCodes.Status204NoContent)] | |||
[ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)] | |||
[ProducesResponseType<uint>(StatusCodes.Status404NotFound)] | |||
public static void Replace(uint id, [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Suffix)] object request) { } | |||
[ProducesResponseType(StatusCodes.Status204NoContent)] | |||
[ProducesResponseType<uint>(StatusCodes.Status404NotFound)] | |||
public static void Update(uint id, [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Suffix)] object patchDocument) { } | |||
[ProducesResponseType(StatusCodes.Status204NoContent)] | |||
[ProducesResponseType<uint>(StatusCodes.Status404NotFound)] | |||
public static void Delete(uint id) { } | |||
} | |||
</filebox> | |||
== [https://medium.com/@celery_liu/asp-net-core-web-api-with-swagger-api-versioning-for-dotnet-8-c8ce2fd7808c Version] == | == [https://medium.com/@celery_liu/asp-net-core-web-api-with-swagger-api-versioning-for-dotnet-8-c8ce2fd7808c Version] == | ||
Ligne 286 : | Ligne 277 : | ||
</filebox> | </filebox> | ||
= OpenAPI metadata = | == Controllers Options == | ||
== [https://learn.microsoft.com/en-us/aspnet/core/fundamentals/openapi/include-metadata?view=aspnetcore-9.0&tabs=controllers#summary-and-description Summary and description] == | === Set "Produces application/json" at the global scope === | ||
<filebox fn='Program.cs'> | |||
builder.Services.AddControllers(options => | |||
{ | |||
// add the ProducesAttribute as a global filter | |||
options.Filters.Add(new ProducesAttribute("application/json")); | |||
// another way to do it | |||
options.OutputFormatters.RemoveType<StringOutputFormatter>(); // remove to prevent automatic content negotiation to plain text | |||
// not needed | |||
var jsonSerializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web) | |||
{ | |||
TypeInfoResolver = new DefaultJsonTypeInfoResolver() | |||
}; | |||
options.OutputFormatters.Add(new SystemTextJsonOutputFormatter(jsonSerializerOptions)); | |||
}); | |||
</filebox> | |||
=== [https://stackoverflow.com/questions/36358751/how-do-you-enforce-lowercase-routing-in-asp-net-core URL with kebab case] === | |||
<filebox fn='Program.cs'> | |||
builder.Services | |||
.AddControllers(options => | |||
{ | |||
options.Conventions.Add(new RouteTokenTransformerConvention(new KebabCaseParameterTransformer())); | |||
}) | |||
</filebox> | |||
<filebox fn='KebabCaseParameterTransformer.cs'> | |||
public partial class KebabCaseParameterTransformer : IOutboundParameterTransformer | |||
{ | |||
[GeneratedRegex("([a-z])([A-Z])")] | |||
private static partial Regex kebabCaseRegex(); | |||
public string? TransformOutbound(object? value) | |||
=> value == null ? null : kebabCaseRegex().Replace(value.ToString(), "$1-$2").ToLower(); | |||
} | |||
</filebox> | |||
= [https://learn.microsoft.com/en-us/aspnet/core/fundamentals/logging/?view=aspnetcore-9.0 Log] = | |||
<filebox fn='Program.cs'> | |||
app.Use(async (context, next) => | |||
{ | |||
if (app.Logger.IsEnabled(LogLevel.Debug)) | |||
{ | |||
context.Request.EnableBuffering(); // Enable rewinding the request body stream | |||
using var reader = new StreamReader(context.Request.Body, leaveOpen: true); | |||
var body = await reader.ReadToEndAsync(); // Read the request body | |||
context.Request.Body.Position = 0; // Reset the request body stream position for further processing | |||
Log.LogRequest( | |||
app.Logger, | |||
context.Request.Method, | |||
context.Request.Path, | |||
string.Join(", ", context.Request.Headers.Select(x => $"{x.Key}={x.Value}")), | |||
body); | |||
} | |||
await next(); | |||
}); | |||
public static partial class Log | |||
{ | |||
[LoggerMessage(Level = LogLevel.Debug, Message = "{Method} {Path}\nHeaders: {Headers}\nBody: {Body}")] | |||
public static partial void LogRequest(ILogger logger, string Method, string Path, string Headers, string Body); | |||
} | |||
</filebox> | |||
= OpenAPI / Swagger = | |||
{{warn | [[Swashbuckle]] is no longer actively maintained and has been remove from .NET 9 templates. [https://github.com/dotnet/aspnetcore/issues/54599 ]}} | |||
<filebox fn='Program.cs'> | |||
// Microsoft.AspNetCore.OpenApi | |||
builder.Services.AddOpenApi(); | |||
</filebox> | |||
URL: {{boxx|http://localhost:xxxx/openapi/v1.json}}<br> | |||
Alternative to Swashbuckle: | |||
* [[Scalar]] | |||
* [[NSwag]] | |||
== OpenAPI metadata == | |||
=== [https://learn.microsoft.com/en-us/aspnet/core/fundamentals/openapi/include-metadata?view=aspnetcore-9.0&tabs=controllers#summary-and-description Summary and description] === | |||
<filebox fn='ItemController.cs'> | <filebox fn='ItemController.cs'> | ||
[EndpointSummary("This is a summary.")] | [EndpointSummary("This is a summary.")] | ||
Ligne 295 : | Ligne 365 : | ||
</filebox> | </filebox> | ||
== [https://learn.microsoft.com/en-us/aspnet/core/fundamentals/openapi/include-metadata?view=aspnetcore-9.0&tabs=controllers#enum enum] == | === [https://learn.microsoft.com/en-us/aspnet/core/fundamentals/openapi/include-metadata?view=aspnetcore-9.0&tabs=controllers#enum enum] === | ||
<filebox fn=''> | <filebox fn=''> | ||
[JsonConverter(typeof(JsonStringEnumConverter<TaskStatus>))] | [JsonConverter(typeof(JsonStringEnumConverter<TaskStatus>))] | ||
Ligne 306 : | Ligne 376 : | ||
</filebox> | </filebox> | ||
== [https://learn.microsoft.com/en-us/aspnet/core/fundamentals/openapi/include-metadata?view=aspnetcore-9.0&tabs=controllers#summary-and-description Model metadata] == | === [https://learn.microsoft.com/en-us/aspnet/core/fundamentals/openapi/include-metadata?view=aspnetcore-9.0&tabs=controllers#summary-and-description Model metadata] === | ||
<filebox fn='Todo.cs'> | <filebox fn='Todo.cs'> | ||
public record Todo( | public record Todo( | ||
Ligne 320 : | Ligne 390 : | ||
</filebox> | </filebox> | ||
= [https://learn.microsoft.com/en-us/aspnet/core/web-api/handle-errors?view=aspnetcore- | = [https://learn.microsoft.com/en-us/aspnet/core/web-api/handle-errors?view=aspnetcore-9.0#problem-details-service Return exceptions as ProblemDetails response] = | ||
{{info | By default exceptions are returned in plain text.<br>With the problem details service it returns now a problem details response.}} | {{info | By default exceptions are returned in plain text.<br>With the problem details service it returns now a problem details response.}} | ||
{{warn | In case of exception the media type returned is {{boxx|application/problem+json}} which is not handled by default on the client side.}} | {{warn | In case of exception the media type returned is {{boxx|application/problem+json}} which is not handled by default on the client side.}} | ||
Ligne 486 : | Ligne 556 : | ||
== Healthcheck UI == | == Healthcheck UI == | ||
{{info | Add the nuget packages | {{info | Add the nuget packages | ||
* {{boxx|AspNetCore.HealthChecks.UI}} | * {{boxx|AspNetCore.HealthChecks.UI}} | ||
Ligne 492 : | Ligne 561 : | ||
* {{boxx|AspNetCore.HealthChecks.UI.InMemory.Storage}}}} | * {{boxx|AspNetCore.HealthChecks.UI.InMemory.Storage}}}} | ||
<filebox fn='Program.cs'> | <filebox fn='Program.cs'> | ||
builder.services | |||
.AddHealthChecksUI(opt => | |||
{ | |||
opt.SetEvaluationTimeInSeconds(120); // time in seconds between check | |||
opt.MaximumHistoryEntriesPerEndpoint(60); // maximum history of checks | |||
opt.SetApiMaxActiveRequests(3); // api requests concurrency | |||
opt.AddHealthCheckEndpoint("API", "/health"); // map health check api | |||
}) | |||
.AddInMemoryStorage(); | |||
app. | app.UseHealthChecksUI(options => | ||
{ | { | ||
options.UIPath = "/healthcheck-ui"; | |||
}); | }); | ||
</filebox> | </filebox> | ||
= CORS = | = CORS = | ||
Ligne 550 : | Ligne 598 : | ||
</filebox> | </filebox> | ||
= [https:// | = [https://www.devtrends.co.uk/blog/handling-errors-in-asp.net-core-web-api UseStatusCodePagesWithReExecute] = | ||
<filebox fn='Startup.cs'> | |||
public void Configure(IApplicationBuilder app, IHostingEnvironment env) | |||
{ | |||
app.UseStatusCodePagesWithReExecute("/error/{0}"); | |||
app.UseExceptionHandler("/error/500"); | |||
app.UseMvc(); | |||
</filebox> | |||
<kode lang='cs'> | |||
[HttpGet("/error/{code:int}")] | |||
public string Error(int code) | |||
{ | |||
return $"Error: {code}"; | |||
} | |||
</kode> | |||
= Action Filter = | |||
<filebox fn='UpdateMyClassFilter.cs'> | |||
public class UpdateMyClassFilter : IActionFilter | |||
{ | |||
public void OnActionExecuting(ActionExecutingContext context) | |||
{ | |||
if (context.ActionArguments.Values.FirstOrDefault(x => x is MyClass) is MyClass myClass) | |||
{ | |||
// do modification on myClass | |||
} | |||
} | |||
public void OnActionExecuted(ActionExecutedContext context) { } | |||
} | |||
</filebox> | |||
<filebox fn='EmptyResultTo204Filter.cs' collapsed> | |||
public class EmptyResultTo204Filter : IActionFilter | |||
{ | |||
public void OnActionExecuting(ActionExecutingContext context) | |||
{ } | |||
public void OnActionExecuted(ActionExecutedContext context) | |||
{ | |||
if (context.Result is EmptyResult) | |||
context.Result = new StatusCodeResult(StatusCodes.Status204NoContent); | |||
} | |||
} | |||
</filebox> | |||
<filebox fn='Program.cs'> | <filebox fn='Program.cs'> | ||
builder.Services | builder.Services | ||
.AddControllers( | .AddControllers(x => | ||
{ | { | ||
x.Filters.Add<MyFilter>(); | |||
}) | }); | ||
</filebox> | </filebox> | ||
<filebox fn=' | = [https://learn.microsoft.com/en-us/aspnet/core/fundamentals/middleware/?view=aspnetcore-9.0 Middleware] = | ||
public | <filebox fn='EnsureNoContentMiddleware.cs'> | ||
/// <summary> | |||
/// A middleware to change the Status Code to 204 when it is equal to 200 and there is no content. | |||
/// </summary> | |||
public class EnsureNoContentMiddleware(RequestDelegate next) | |||
{ | { | ||
public async Task InvokeAsync(HttpContext context) | |||
{ | |||
// Call the next middleware in the pipeline | |||
await next(context); | |||
// If the response has no content and no explicit status code is set, default to 204 No Content | |||
= | if (context.Response.StatusCode == StatusCodes.Status200OK | ||
&& context.Response.ContentLength == null | |||
&& !context.Response.HasStarted) | |||
{ | |||
context.Response.StatusCode = StatusCodes.Status204NoContent; | |||
} | |||
} | |||
} | } | ||
</filebox> | |||
<filebox fn='Program.cs'> | |||
app.UseMiddleware<EnsureNoContentMiddleware>(); | |||
</filebox> | </filebox> | ||
Ligne 1 190 : | Ligne 1 301 : | ||
return queryBuilder.ToQueryString(); | return queryBuilder.ToQueryString(); | ||
} | } | ||
</filebox> | </filebox> | ||
Dernière version du 6 avril 2025 à 17:28
Links
New projet
dotnet new webapi -o [project-name] --no-https |
Debug launch profiles / launchSettings
![]() |
On VS: right-click on the project → Properties → Debug → General → Open debug launch profiles UI |
Properties/launchSettings.json |
{ "profiles": { "http": { // kestrel profile "commandName": "Project", "launchBrowser": true, "launchUrl": "http://localhost:5000/scalar", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }, "dotnetRunMessages": true, "applicationUrl": "http://localhost:5000" } }, "$schema": "https://json.schemastore.org/launchsettings.json" } |
Controller
Controllers/ItemsController.cs |
[ApiController] [Route("[controller]")] // /item [Produces("application/json")] // inform about the output format, default are text/json and application/json public class ItemController(IItemService itemService) : ControllerBase { /* ... */ } |
GET
By id
[HttpGet("{id:int}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<ActionResult<ItemResponse>> GetByIdAsync(int id, CancellationToken cancellationToken) { var item = await context.Items .AsNoTracking() .FindAsync(id, cancellationToken); return item is null ? NotFound() : Ok(mapper.Map<ItemResponse>(item)); } |
Search
[HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)] public async Task<ActionResult<ItemResponse[]>> GetAsync([FromQuery] GetItemsRequest getItemsRequest, CancellationToken cancellationToken) { var predicate = PredicateBuilder.New<Item>(true); if (!string.IsNullOrEmpty(getItemsRequest.Name)) predicate = predicate.And(x => EF.Functions.Like(x.Name, $"%{getItemsRequest.Name}%")); if (getItemsRequest.Ids != null && getItemsRequest.Ids.Count > 0) predicate = predicate.And(x => getItemsRequest.Ids.Contains(x.Id)); var items = await context.Items .Where(predicate) .AsNoTracking() .ToListAsync(cancellationToken); return Ok(mapper.Map<ItemResponse[]>(items)); } |
POST
[HttpPost] [ProducesResponseType(StatusCodes.Status201Created)] [ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)] public async Task<ActionResult<ItemResponse>> CreateAsync(CreateUpdateItemQuery query, CancellationToken cancellationToken) { var item = this.mapper.Map<Item>(query); await context.AddAsync(item, cancellationToken); await context.SaveChangesAsync(cancellationToken); var response = this.mapper.Map<ItemResponse>(item); var actionName = nameof(GetByIdAsync); // to handle Async suffix see the warning box below var routeValues = new { id = item.Id }; // path and query parameters return CreatedAtAction(actionName, routeValues, response); return CreatedAtRoute(new { response.Id }, response); // Location header seems wrong: /items?id=5 } |
![]() |
To handle Async suffix in CreatedAtAction
|
PUT (replace)
[HttpPut("{id:int}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<IActionResult> UpdateAsync(int id, CreateUpdateItemQuery query, CancellationToken cancellationToken) { var itemToUpdate = await context.Items.FindAsync(id, cancellationToken); if (itemToUpdate is null) return NotFound(); this.mapper.Map(query, itemToUpdate); await context.SaveChangesAsync(cancellationToken); return NoContent(); } |
PATCH
![]() |
The System.Text.Json-based input formatter doesn't support JSON Patch. It is possible to add support for JSON Patch using Newtonsoft.Json, while leaving the other input and output formatters unchanged. |
- Install Microsoft.AspNetCore.Mvc.NewtonsoftJson nuget package
Program.cs |
// NewtonsoftJsonPatchInputFormatter processes JSON Patch requests in first // The existing System.Text.Json-based input and formatters process all other JSON requests and responses builder.Services.AddControllers(options => { options.InputFormatters.Insert(0, JsonPatchInputFormatter.GetJsonPatchInputFormatter()); }); |
JsonPatchInputFormatter.cs |
public static class JsonPatchInputFormatter { public static NewtonsoftJsonPatchInputFormatter GetJsonPatchInputFormatter() { var builder = new ServiceCollection() .AddLogging() .AddMvc() .AddNewtonsoftJson() .Services.BuildServiceProvider(); return builder .GetRequiredService<IOptions<MvcOptions>>() .Value .InputFormatters .OfType<NewtonsoftJsonPatchInputFormatter>() .First(); } } |
[HttpPatch("{id:int}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<IActionResult> PatchAsync(int id, [FromBody] JsonPatchDocument<Item> itemPatchDoccument, CancellationToken cancellationToken) { if (itemPatchDoccument is null) return BadRequest(); var itemToUpdate = await items.FindAsync(id, cancellationToken); if (itemToUpdate is null) return NotFound(); itemPatchDoccument.ApplyTo(itemToUpdate, ModelState); if (!ModelState.IsValid) return BadRequest(ModelState); await context.SaveChangesAsync(cancellationToken); return NoContent(); } |
![]() |
Ensure client header Content-Type = application/json-patch+json |
DELETE
[HttpDelete("{id:int}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<IActionResult> DeleteAsync(int id, CancellationToken cancellationToken) { if (!await context.Items.AnyAsync(x => x.Id == id, cancellationToken)) return NotFound(); context.Remove(new Item { Id = id }); await context.SaveChangesAsync(cancellationToken); return NoContent(); } |
Headers
public IActionResult Get([FromHeader(Name = "Your-Specific-Header")] string headerValue) { } if (Request.Headers.TryGetValue("Your-Specific-Header", out var headerValue)) { var specificHeaderValue = headerValue.ToString(); } Response.Headers.Append("Key", "Value"); |
Web API conventions
By default, the web API returns an HTTP code 200. It is possible to define a custom web API convention:
Progam.cs |
[assembly: ApiConventionType(typeof(ApiConventions))] |
ApiConventions.cs |
public static class ApiConventions { [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType<uint>(StatusCodes.Status404NotFound)] public static void Get(uint id) { } [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)] public static void Get([ApiConventionNameMatch(ApiConventionNameMatchBehavior.Suffix)] object request) { } [ProducesResponseType(StatusCodes.Status201Created)] [ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)] public static void Create([ApiConventionNameMatch(ApiConventionNameMatchBehavior.Suffix)] object request) { } [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)] [ProducesResponseType<uint>(StatusCodes.Status404NotFound)] public static void Replace(uint id, [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Suffix)] object request) { } [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType<uint>(StatusCodes.Status404NotFound)] public static void Update(uint id, [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Suffix)] object patchDocument) { } [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType<uint>(StatusCodes.Status404NotFound)] public static void Delete(uint id) { } } |
Version
Program.cs |
// nuget package Asp.Versioning.Mvc builder.Services .AddApiVersioning(options => { options.ReportApiVersions = true; options.AssumeDefaultVersionWhenUnspecified = true; options.DefaultApiVersion = new ApiVersion(1, 0); }); |
ItemController.cs |
[ApiVersion("1.0")] [Route("v{version:apiVersion}/[controller]")] [ApiController] public class ItemController : ControllerBase { } |
Controllers Options
Set "Produces application/json" at the global scope
Program.cs |
builder.Services.AddControllers(options => { // add the ProducesAttribute as a global filter options.Filters.Add(new ProducesAttribute("application/json")); // another way to do it options.OutputFormatters.RemoveType<StringOutputFormatter>(); // remove to prevent automatic content negotiation to plain text // not needed var jsonSerializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web) { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; options.OutputFormatters.Add(new SystemTextJsonOutputFormatter(jsonSerializerOptions)); }); |
URL with kebab case
Program.cs |
builder.Services .AddControllers(options => { options.Conventions.Add(new RouteTokenTransformerConvention(new KebabCaseParameterTransformer())); }) |
KebabCaseParameterTransformer.cs |
public partial class KebabCaseParameterTransformer : IOutboundParameterTransformer { [GeneratedRegex("([a-z])([A-Z])")] private static partial Regex kebabCaseRegex(); public string? TransformOutbound(object? value) => value == null ? null : kebabCaseRegex().Replace(value.ToString(), "$1-$2").ToLower(); } |
Log
Program.cs |
app.Use(async (context, next) => { if (app.Logger.IsEnabled(LogLevel.Debug)) { context.Request.EnableBuffering(); // Enable rewinding the request body stream using var reader = new StreamReader(context.Request.Body, leaveOpen: true); var body = await reader.ReadToEndAsync(); // Read the request body context.Request.Body.Position = 0; // Reset the request body stream position for further processing Log.LogRequest( app.Logger, context.Request.Method, context.Request.Path, string.Join(", ", context.Request.Headers.Select(x => $"{x.Key}={x.Value}")), body); } await next(); }); public static partial class Log { [LoggerMessage(Level = LogLevel.Debug, Message = "{Method} {Path}\nHeaders: {Headers}\nBody: {Body}")] public static partial void LogRequest(ILogger logger, string Method, string Path, string Headers, string Body); } |
OpenAPI / Swagger
![]() |
Swashbuckle is no longer actively maintained and has been remove from .NET 9 templates. [1] |
Program.cs |
// Microsoft.AspNetCore.OpenApi builder.Services.AddOpenApi(); |
URL: http://localhost:xxxx/openapi/v1.json
Alternative to Swashbuckle:
OpenAPI metadata
Summary and description
ItemController.cs |
[EndpointSummary("This is a summary.")] [EndpointDescription("This is a description.")] [HttpGet] public IActionResult Get([Description("This is a description.")] Request request) { } |
enum
[JsonConverter(typeof(JsonStringEnumConverter<TaskStatus>))] public enum TaskStatus { ReadyToStart, InProgress, Completed } |
Model metadata
Todo.cs |
public record Todo( [property: Required] [property: Description("The unique identifier for the todo")] int Id, [property: Description("The title of the todo")] [property: MaxLength(120)] string Title, [property: Description("Whether the todo has been completed")] bool Completed ) {} |
Return exceptions as ProblemDetails response
![]() |
By default exceptions are returned in plain text. With the problem details service it returns now a problem details response. |
![]() |
In case of exception the media type returned is application/problem+json which is not handled by default on the client side. |
Program.cs |
//builder.Services.AddControllers(); builder.Services.AddProblemDetails(); //var app = builder.Build(); app.UseExceptionHandler(); app.UseStatusCodePages(); if (app.Environment.IsDevelopment()) { app.UseDeveloperExceptionPage(); } //app.MapControllers(); //app.Run(); |
OLD Exception handler
Program.cs |
//var app = builder.Build(); if (app.Environment.IsDevelopment()) { app.UseExceptionHandler("/error-development"); } else { app.UseExceptionHandler("/error"); } //app.UseAuthorization(); |
Controllers/ErrorController.cs |
[ApiController] public class ErrorController : ControllerBase { [Route("/error")] [ApiExplorerSettings(IgnoreApi = true)] public IActionResult HandleError() => Problem(); [Route("/error-development")] [ApiExplorerSettings(IgnoreApi = true)] public IActionResult HandleErrorDevelopment([FromServices] IHostEnvironment hostEnvironment) { if (!hostEnvironment.IsDevelopment()) { return NotFound(); } var exceptionHandlerFeature = HttpContext.Features.Get<IExceptionHandlerFeature>()!; return Problem( detail: exceptionHandlerFeature.Error.StackTrace, title: GetFinalInnerException(exceptionHandlerFeature.Error).Message); static Exception GetFinalInnerException(Exception exception) => exception.InnerException is null ? exception : GetFinalInnerException(exception.InnerException); } } |
Handle application/problem+json on the client side
Clients/ItemClient.cs |
private static IEnumerable<MediaTypeFormatter> problemJsonMediaTypeFormatters => new MediaTypeFormatter[1] { new ProblemJsonMediaTypeFormatter() }; var response = await httpClient.GetAsync(uri, cancellationToken); if (response.StatusCode == System.Net.HttpStatusCode.BadRequest) { var validationProblemDetails = await response.Content.ReadAsAsync<ValidationProblemDetails>( problemJsonMediaTypeFormatters, cancellationToken); |
MediaTypeFormatters/ProblemJsonMediaTypeFormatter.cs |
public class ProblemJsonMediaTypeFormatter : JsonMediaTypeFormatter { private static readonly MediaTypeHeaderValue problemJsonMediaType = new("application/problem+json"); public ProblemJsonMediaTypeFormatter() { SupportedMediaTypes.Add(problemJsonMediaType); } } |
Fluent Validation
dotnet add package FluentValidation.AspNetCore |
Program.cs |
//builder.Services.AddControllers(); builder.Services.AddFluentValidationAutoValidation(); builder.Services.AddValidatorsFromAssemblyContaining<Program>(); |
Validators/ItemValidator.cs |
public class CreateItemQueryValidator : AbstractValidator<CreateItemQuery> { public CreateItemQueryValidator() { RuleFor(x => x.Name).NotEmpty(); RuleFor(x => x.Description) .MinimumLength(3) .Unless(x => string.IsNullOrEmpty(x.Description)); // empty or min lenght 3 |
Health check
![]() |
Add the nuget packages Microsoft.Extensions.Diagnostics.HealthChecks |
Program.cs |
//var builder = WebApplication.CreateBuilder(args); builder.Services .AddHealthChecks() .AddMongoDb(builder.Configuration["MongoConnectionStringKey"]) // MongoDB with AspNetCore.HealthChecks.MongoDb nuget package .AddDbContextCheck<MyAppContext>() // EF .AddCheck<ExternalApiHealthCheck>("External API"); //var app = builder.Build(); // a basic health check app.MapHealthChecks("/health", new HealthCheckOptions { Predicate = _ => false; }); // a detailed health check app.MapHealthChecks("/healthz", new HealthCheckOptions { ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse // JSON UI-friendly response (need AspNetCore.HealthChecks.UI.Client) }); public class ExternalApiHealthCheck(HttpClient httpClient) : IHealthCheck { public async Task<HealthCheckResult> CheckHealthAsync( HealthCheckContext context, CancellationToken cancellationToken = default) { var response = await httpClient.GetAsync("health", cancellationToken); return response.IsSuccessStatusCode ? HealthCheckResult.Healthy($"External API is up and running.") : HealthCheckResult.Unhealthy("External API is down."); } } |
HTTP Code | Description |
---|---|
200 | healthy |
503 | unhealthy |
Healthcheck UI
![]() |
Add the nuget packages
|
Program.cs |
builder.services .AddHealthChecksUI(opt => { opt.SetEvaluationTimeInSeconds(120); // time in seconds between check opt.MaximumHistoryEntriesPerEndpoint(60); // maximum history of checks opt.SetApiMaxActiveRequests(3); // api requests concurrency opt.AddHealthCheckEndpoint("API", "/health"); // map health check api }) .AddInMemoryStorage(); app.UseHealthChecksUI(options => { options.UIPath = "/healthcheck-ui"; }); |
CORS
Programs.cs |
// allow all origins app.UseCors(x => x.SetIsOriginAllowed(_ => true)); // allow https://localhost:4200 app.UseCors(x => x.WithOrigins("https://localhost:4200")); // use policy builder.Services.AddCors(options => { options.AddPolicy("AllowSpecificOrigin", policy => { policy.WithOrigins("https://my-domain.net") .AllowAnyMethod() .AllowAnyHeader(); }); }); app.UseCors("AllowSpecificOrigin"); |
UseStatusCodePagesWithReExecute
Startup.cs |
public void Configure(IApplicationBuilder app, IHostingEnvironment env) { app.UseStatusCodePagesWithReExecute("/error/{0}"); app.UseExceptionHandler("/error/500"); app.UseMvc(); |
[HttpGet("/error/{code:int}")] public string Error(int code) { return $"Error: {code}"; } |
Action Filter
UpdateMyClassFilter.cs |
public class UpdateMyClassFilter : IActionFilter { public void OnActionExecuting(ActionExecutingContext context) { if (context.ActionArguments.Values.FirstOrDefault(x => x is MyClass) is MyClass myClass) { // do modification on myClass } } public void OnActionExecuted(ActionExecutedContext context) { } } |
EmptyResultTo204Filter.cs |
public class EmptyResultTo204Filter : IActionFilter { public void OnActionExecuting(ActionExecutingContext context) { } public void OnActionExecuted(ActionExecutedContext context) { if (context.Result is EmptyResult) context.Result = new StatusCodeResult(StatusCodes.Status204NoContent); } } |
Program.cs |
builder.Services .AddControllers(x => { x.Filters.Add<MyFilter>(); }); |
Middleware
EnsureNoContentMiddleware.cs |
/// <summary> /// A middleware to change the Status Code to 204 when it is equal to 200 and there is no content. /// </summary> public class EnsureNoContentMiddleware(RequestDelegate next) { public async Task InvokeAsync(HttpContext context) { // Call the next middleware in the pipeline await next(context); // If the response has no content and no explicit status code is set, default to 204 No Content if (context.Response.StatusCode == StatusCodes.Status200OK && context.Response.ContentLength == null && !context.Response.HasStarted) { context.Response.StatusCode = StatusCodes.Status204NoContent; } } } |
Program.cs |
app.UseMiddleware<EnsureNoContentMiddleware>(); |
Client
Client/ItemClient.cs |
public class ItemClient : IItemClient { private const string PATH = "item"; private const string APIVERSION = "1"; private readonly HttpClient httpClient; private readonly QueryBuilder queryBuilder; // injection de HttpClient public ItemClient(HttpClient httpClient) { this.httpClient = httpClient; queryBuilder = new QueryBuilder { { "api-version", APIVERSION } }; } |
Clients/OperationResult.cs |
public class OperationResult<T> { private readonly IDictionary<string, string[]> validationErrors; public T? Result { get; } public IReadOnlyCollection<string> ErrorMessages => validationErrors.SelectMany(x => x.Value).ToList(); public bool IsValid => validationErrors.Count == 0; // used to handle errors of server validation public OperationResult(IDictionary<string, string[]> validationErrors) { this.validationErrors = validationErrors; } // used to handle errros of client validation with FluentValidation public OperationResult(IEnumerable<ValidationFailure> validationFailures) { validationErrors = validationFailures .GroupBy(x => x.PropertyName, x => x.ErrorMessage) .ToDictionary(x => x.Key, x => x.ToArray()); } // used to handle error public OperationResult(string errorMessage) { validationErrors = new Dictionary<string, string[]> { { string.Empty, new string[] { errorMessage } } }; } // use to hanlde valid result public OperationResult(T result) { Result = result; validationErrors = new Dictionary<string, string[]>(); } } |
Clients/OperationStatus.cs |
public class OperationStatus { public string ErrorMessage { get; } public IDictionary<string, string[]> Errors { get; } public bool Success => string.IsNullOrEmpty(ErrorMessage) && Errors.Count == 0; // use to hanlde valid status public OperationStatus() { ErrorMessage = string.Empty; Errors = new Dictionary<string, string[]>(); } // used to handle single error of server validation public OperationStatus(string errorMessage) { ErrorMessage = errorMessage; Errors = new Dictionary<string, string[]>(); } // used to handle errors of server validation public OperationStatus(IDictionary<string, string[]> errors) { ErrorMessage = string.Empty; Errors = errors; } } |
Extensions/QueryExtensions.cs |
public static class QueryExtensions { public static QueryString ToQueryString(this FetchItemQuery query) { var queryBuilder = new QueryBuilder { { nameof(query.PageIndex), query.PageIndex.ToString(CultureInfo.InvariantCulture) }, { nameof(query.PageSize), query.PageSize.ToString(CultureInfo.InvariantCulture) } }; queryBuilder.AddIfValueNotNullNorEmpty(nameof(query.MyProperty), query.MyProperty); return queryBuilder.ToQueryString(); // ?key1=value1,key2=value2 } } |
Extensions/QueryBuilderExtensions.cs |
public static class QueryBuilderExtensions { public static void AddIfValueNotNullNorEmpty(this QueryBuilder source, string key, string value) { if (string.IsNullOrEmpty(value)) { return; } source.Add(key, value); } } |
Dependency Injection
Typed clients are transient objects usually injected into services.
They are expected to be short-lived.
If a typed client instance is captured in a singleton, it may prevent it from reacting to DNS changes, defeating one of the purposes of IHttpClientFactory.
Program.cs |
services .AddHttpClient<IMyClient, MyClient>(x => { x.DefaultRequestHeaders.Authorization = // AuthenticationHeaderValue see below x.BaseAddress = new Uri("https://www.address.net"); x.DefaultRequestHeaders.UserAgent.ParseAdd("my-app"); x.Timeout = TimeSpan.FromSeconds(100); // default 100s }) .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { Proxy = new WebProxy { Address = new Uri($"http://localhost:9000"), BypassProxyOnLocal = false, UseDefaultCredentials = false, }, }); |
Basic Base64 user password authentication
var authenticationString = $"{username}:{password}"; var base64EncodedAuthenticationString = Convert.ToBase64String(Encoding.ASCII.GetBytes(authenticationString)); x.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", base64EncodedAuthenticationString); |
AAD OAuth2 JWT authentication
var builder = ConfidentialClientApplicationBuilder .Create(clientId) .WithClientSecret(clientSecret) .WithAuthority(new Uri(authorityUri)) .Build(); var result = await builder .AcquireTokenForClient([scope]) .ExecuteAsync(); x.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", result.AccessToken); |
Cognito OAuth2 JWT authentication
var request = new HttpRequestMessage(HttpMethod.Post, tokenEndPoint); var authHeader = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{clientId}:{clientSecret}")); request.Headers.Authorization = new AuthenticationHeaderValue("Basic", authHeader); request.Content = new FormUrlEncodedContent( [ new KeyValuePair<string, string>("grant_type", "client_credentials"), new KeyValuePair<string, string>("scope", scope) ]); using var httpClient = new HttpClient(); var response = await httpClient.SendAsync(request); response.EnsureSuccessStatusCode(); var responseContent = await response.Content.ReadAsStringAsync(); var tokenResponse = JObject.Parse(responseContent); var accessToken = tokenResponse["access_token"].ToString(); x.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); |
Cache
Program.cs |
services.AddMemoryCache(); // inject IMemoryCache |
MyClient.cs |
public class MyClient(HttpClient httpClient, IMemoryCache cache) : IMyClient { private static readonly TimeSpan expiration = TimeSpan.FromHours(1); private async Task<Item[]> GetItemsAsync() { var uri = "items"; if (cache.TryGetValue(uri, out Item[]? cachedResponse)) return cachedResponse; var response = await httpClient.GetAsync(uri); if (!response.IsSuccessStatusCode) { // handle error var errorMessage = $"url: {httpClient.BaseAddress}/{uri}, status code: {response.StatusCode}, content: {await response.Content.ReadAsStringAsync()}"; return []; } var content = await response.Content.ReadFromJsonAsync<Item[]?>(); cache.Set(uri, content, expiration); return content; } |
GET
// all in 1: get response, EnsureSuccessStatusCode, then deserialize the content var items = await httpClient.GetFromJsonAsync<ItemResponse[]>(uri) ?? []; // step by step var response = await HttpClient.GetAsync(uri, cancellationToken); if (response.StatusCode == HttpStatusCode.???) // handle status code error response.EnsureSuccessStatusCode(); var items = (await response.Content.ReadFromJsonAsync<ItemResponse[]>(cancellationToken)) ?? []; |
Json Converters
public class ItemResponse { [JsonConverter(typeof(JsonStringEnumConverter))] // allow (de)serialization from/to string public ItemType ItemType { get; set; } [JsonConverter(typeof(JsonStringBoolConverter))] // allow (de)serialization from/to string public bool Archived { get; set; } } public enum ItemType { Main, Specific } |
JsonStringBoolConverter.cs |
public class JsonStringBoolConverter: JsonConverter<bool> { public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options) => writer.WriteBooleanValue(value); public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => reader.TokenType switch { JsonTokenType.True => true, JsonTokenType.False => false, JsonTokenType.String => bool.TryParse(reader.GetString(), out var b) ? b : throw new JsonException(), JsonTokenType.Number => reader.TryGetInt64(out var l) ? Convert.ToBoolean(l) : reader.TryGetDouble(out var d) && Convert.ToBoolean(d), _ => throw new JsonException(), }; } |
POST
public async Task<OperationStatus> CreateAsync(CreateUpdateItemQuery query) { ArgumentNullException.ThrowIfNull(query); var uri = new Uri(PATH, UriKind.Relative); var response = await httpClient.PostAsJsonAsync(uri, query); // old way var queryJson = new StringContent(JsonSerializer.Serialize(query), Encoding.UTF8, "application/json"); var response = await httpClient.PostAsync(uri, queryJson); if (response.StatusCode == HttpStatusCode.BadRequest) { var validationProblemDetails = await response.Content.ReadAsAsync<ValidationProblemDetails>( problemJsonMediaTypeFormatters, cancellationToken); return validationProblemDetails is null ? throw new InvalidDataException($"{nameof(validationProblemDetails)} is null") : new OperationStatus(validationProblemDetails.Errors); } response.EnsureSuccessStatusCode(); // var item = await response.Content.ReadFromJsonAsync<ItemResponse>(cancellationToken); return new OperationStatus(); |
PUT
public async Task<OperationStatus> UpdateAsync(int id, CreateUpdateItemQuery query, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(query); var uri = new Uri($"{Path}/{id}", UriKind.Relative); var queryJson = new StringContent(JsonSerializer.Serialize(query), Encoding.UTF8, "application/json"); var response = await httpClient.PutAsync(uri, queryJson, cancellationToken); if (response.StatusCode == HttpStatusCode.BadRequest) { var validationProblemDetails = await response.Content.ReadAsAsync<ValidationProblemDetails>( problemJsonMediaTypeFormatters, cancellationToken); return validationProblemDetails is null ? throw new InvalidDataException($"{nameof(validationProblemDetails)} is null") : new OperationStatus(validationProblemDetails.Errors); } if (response.StatusCode == HttpStatusCode.NotFound) { return new OperationStatus($"Item {id} has not been found."); } response.EnsureSuccessStatusCode(); return new OperationStatus(); |
DELETE
public async Task<OperationStatus> DeleteAsync(int id, CancellationToken cancellationToken) { var uri = new Uri($"{Path}/{id}", UriKind.Relative); var response = await httpClient.DeleteAsync(uri, cancellationToken); if (response.StatusCode == HttpStatusCode.NotFound) { return new OperationStatus($"Item {id} has not been found."); } if (response.StatusCode == HttpStatusCode.InternalServerError) { var problemDetails = await response.Content.ReadAsAsync<ProblemDetails>( problemJsonMediaTypeFormatters, cancellationToken); var errorMessage = problemDetails is null || string.IsNullOrEmpty(problemDetails.Detail) ? "Unable to read the ProblemDetails error message" : problemDetails.Detail.Replace(" See the inner exception for details.", string.Empty); return new OperationStatus(errorMessage); } response.EnsureSuccessStatusCode(); return new OperationStatus(); |
ClientBase
ClientBase for
- GetById
- GetAll
- Create
- Update
- Delete
ItemClient.cs |
public class ItemClient : ClientBaseWithGetAll<ItemResponse, CreateUpdateItemQuery>, IItemClient { protected override string Path => "item"; public ItemClient(HttpClient httpClient) : base(httpClient) { } } |
IItemClient.cs |
public interface IItemClient : IClientBaseWithGetAll<ItemResponse, CreateUpdateItemQuery> { } |
ClientBase.cs |
public abstract class ClientBase<TResponse, TCreateUpdateQuery> : IClientBase<TResponse, TCreateUpdateQuery> { protected static IEnumerable<MediaTypeFormatter> problemJsonMediaTypeFormatters => new MediaTypeFormatter[1] { new ProblemJsonMediaTypeFormatter() }; protected abstract string Path { get; } protected HttpClient HttpClient { get; } protected ClientBase(HttpClient httpClient) { HttpClient = httpClient; } public async Task<OperationResult<TResponse>> GetByIdAsync( int id, CancellationToken cancellationToken) { var uri = new Uri($"{Path}/{id}", UriKind.Relative); var response = await HttpClient.GetAsync(uri, cancellationToken); if (response.StatusCode == HttpStatusCode.NotFound) { return new OperationResult<TResponse>($"{typeof(TResponse)} {id} has not been found."); } response.EnsureSuccessStatusCode(); var content = await response.Content.ReadAsAsync<TResponse>(cancellationToken); return new OperationResult<TResponse>(content); } public async Task<OperationStatus> DeleteAsync(int id, CancellationToken cancellationToken) { var uri = new Uri($"{Path}/{id}", UriKind.Relative); var response = await HttpClient.DeleteAsync(uri, cancellationToken); if (response.StatusCode == HttpStatusCode.NotFound) { return new OperationStatus($"{typeof(TResponse)} {id} has not been found."); } if (response.StatusCode == HttpStatusCode.InternalServerError) { var problemDetails = await response.Content.ReadAsAsync<ProblemDetails>( problemJsonMediaTypeFormatters, cancellationToken); var errorMessage = problemDetails is null || string.IsNullOrEmpty(problemDetails.Detail) ? "Unable to read the ProblemDetails error message" : problemDetails.Detail.Replace(" See the inner exception for details.", string.Empty); return new OperationStatus(errorMessage); } response.EnsureSuccessStatusCode(); return new OperationStatus(); } public async Task<OperationStatus> CreateAsync( TCreateUpdateQuery query, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(query); var uri = new Uri(Path, UriKind.Relative); var queryJson = new StringContent(JsonSerializer.Serialize(query), Encoding.UTF8, "application/json"); var response = await HttpClient.PostAsync(uri, queryJson, cancellationToken); if (response.StatusCode == HttpStatusCode.BadRequest) { var validationProblemDetails = await response.Content.ReadAsAsync<ValidationProblemDetails>( problemJsonMediaTypeFormatters, cancellationToken); return validationProblemDetails is null ? throw new InvalidDataException($"{nameof(validationProblemDetails)} is null") : new OperationStatus(validationProblemDetails.Errors); } response.EnsureSuccessStatusCode(); return new OperationStatus(); } public async Task<OperationStatus> UpdateAsync( int id, TCreateUpdateQuery query, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(query); var uri = new Uri($"{Path}/{id}", UriKind.Relative); var queryJson = new StringContent(JsonSerializer.Serialize(query), Encoding.UTF8, "application/json"); var response = await HttpClient.PutAsync(uri, queryJson, cancellationToken); if (response.StatusCode == HttpStatusCode.BadRequest) { var validationProblemDetails = await response.Content.ReadAsAsync<ValidationProblemDetails>( problemJsonMediaTypeFormatters, cancellationToken); return validationProblemDetails is null ? throw new InvalidDataException($"{nameof(validationProblemDetails)} is null") : new OperationStatus(validationProblemDetails.Errors); } if (response.StatusCode == HttpStatusCode.NotFound) { return new OperationStatus($"{typeof(TResponse)} {id} has not been found."); } response.EnsureSuccessStatusCode(); return new OperationStatus(); } } |
IClientBase.cs |
public interface IClientBase<TResponse, TCreateUpdateQuery> { Task<OperationResult<TResponse>> GetByIdAsync(int id, CancellationToken cancellationToken); Task<OperationStatus> DeleteAsync(int id, CancellationToken cancellationToken); Task<OperationStatus> CreateAsync( TCreateUpdateQuery query, CancellationToken cancellationToken); Task<OperationStatus> UpdateAsync( int id, TCreateUpdateQuery query, CancellationToken cancellationToken); } |
ClientBaseWithGetAll.cs |
public abstract class ClientBaseWithGetAll<TResponse, TCreateUpdateQuery> : ClientBase<TResponse, TCreateUpdateQuery> { protected ClientBaseWithGetAll(HttpClient httpClient) : base(httpClient) { } public async Task<IReadOnlyCollection<TResponse>> GetAllAsync( CancellationToken cancellationToken) { var uri = new Uri(Path, UriKind.Relative); var response = await HttpClient.GetAsync(uri, cancellationToken); response.EnsureSuccessStatusCode(); var content = await response.Content .ReadAsAsync<IReadOnlyCollection<TResponse>>(cancellationToken); return content; } } |
IClientBaseWithGetAll.cs |
public interface IClientBaseWithGetAll<TResponse, TCreateUpdateQuery> : IClientBase<TResponse, TCreateUpdateQuery> { Task<IReadOnlyCollection<TResponse>> GetAllAsync(CancellationToken cancellationToken); } |
Query Strings
Url: http://localhost:80/api/items?ids=1,2&retrieveDeleted=true
ItemClient.cs |
var ids = new[] { 1, 2 }; var retrieveDeleted = true; var queryBuilder = new QueryBuilder(); queryBuilder.Add(nameof(ids), string.Join(",", ids)); queryBuilder.Add(nameof(retrieveDeleted), retrieveDeleted.ToString()); var uri = new Uri($"{Path}{queryBuilder.ToQueryString()}", UriKind.Relative); var response = await this.httpClient.GetAsync(uri); |
ItemController.cs |
[HttpGet] public IActionResult Get([FromQuery] IReadOnlyCollection<int> ids, [FromQuery] bool retrieveDeleted = false) { } // FromQuery is required for IReadOnlyCollection<int> to avoid having 415 Unsupported Media Type |
Pagination
ItemQuery.cs |
public class ItemQuery { public string Name { get; set; } public int PageIndex { get; set; } public int PageSize { get; set; } } |
ItemClient.cs |
public async Task<IReadOnlyCollection<ItemDto>> GetAsync() { var query = new ItemQuery { ProfileName = string.Empty, PageIndex = 0, PageSize = 10 }; var uri = new Uri($"{Path}{query.ToQueryString()}", UriKind.Relative); |
ItemQueryExtension.cs |
public static QueryString ToQueryString(this ItemQuery itemQuery) { var queryBuilder = new QueryBuilder(); queryBuilder.Add(nameof(itemQuery.ProfileName), itemQuery.Name); queryBuilder.Add(nameof(itemQuery.PageIndex), itemQuery.PageIndex.ToString()); queryBuilder.Add(nameof(itemQuery.PageSize), itemQuery.PageSize.ToString()); return queryBuilder.ToQueryString(); } |
The Operation Result Pattern
OperationResult.cs |
public sealed class OperationResult<TResult, TError> { public TResult Result { get; } public TError Error { get; } public bool Success => Error == null; public OperationResult(TResult result) { Result = result; } public OperationResult(TError error) { Error = error; } } |
OperationResult<MyDto, MyError> result; if (/*...*/) { result = new OperationResult<MyDto, MyError>(myError); } else { result = new OperationResult<MyDto, MyError>(myDto); } return result; |
JSON name mapping
[JsonPropertyName("otherName")] public string Property1 { get; set; } |
JSON serialization: Newtonsoft.Json vs System.Text.Json
By default .NET Core 3.0 uses System.Text.Json.
Use Newtonsoft.Json
dotnet add package Microsoft.AspNetCore.Mvc.NewtonsoftJson |
Startup.cs |
public void ConfigureServices(IServiceCollection services) { services.AddControllers(); .AddNewtonsoftJson(); |
Model Binding
Program.cs |
builder.Services.AddControllers(options => { options.ModelBinderProviders.Insert(0, new CommaSeparatedModelBinderProvider()); }); |
CommaSeparatedModelBinderProvider.cs |
public class CommaSeparatedModelBinderProvider : IModelBinderProvider { public IModelBinder? GetBinder(ModelBinderProviderContext context) { ArgumentNullException.ThrowIfNull(context); if (context.Metadata.ModelType.GetInterface(nameof(IEnumerable)) != null && context.Metadata.ModelType != typeof(string)) { return new BinderTypeModelBinder(typeof(CommaSeparatedModelBinder)); } return null; } } |
CommaSeparatedModelBinder.cs |
public class CommaSeparatedModelBinder : IModelBinder { private static readonly MethodInfo ToArrayMethod = typeof(Enumerable).GetMethod("ToArray")!; public Task BindModelAsync(ModelBindingContext bindingContext) { ArgumentNullException.ThrowIfNull(bindingContext); var modelType = bindingContext.ModelType; var modelName = bindingContext.ModelName; var valueType = modelType.GetElementType() ?? modelType.GetGenericArguments().FirstOrDefault(); if (modelType.GetInterface(nameof(IEnumerable)) != null && valueType != null && valueType.GetInterface(nameof(IConvertible)) != null) { var valueProviderResult = bindingContext.ValueProvider.GetValue(modelName); if (valueProviderResult == ValueProviderResult.None) { return Task.CompletedTask; } bindingContext.ModelState.SetModelValue(modelName, valueProviderResult); var value = valueProviderResult.FirstValue; // Check if the argument value is null or empty if (string.IsNullOrEmpty(value)) { return Task.CompletedTask; } var list = (IList?)Activator.CreateInstance(typeof(List<>).MakeGenericType(valueType)); if (list is not null) { foreach (var splitValue in value.Split(commaSeparator)) { if (!string.IsNullOrWhiteSpace(splitValue)) { list.Add(Convert.ChangeType(splitValue, valueType)); } } } var model = modelType.IsArray ? ToArrayMethod.MakeGenericMethod(valueType).Invoke(this, new[] { list }) : list; bindingContext.Result = ModelBindingResult.Success(model); } return Task.CompletedTask; } } |
Handle files
FileClient.cs |
await using var stream = System.IO.File.OpenRead("/path/MyFile.txt"); using var content = new MultipartFormDataContent { { new StreamContent(stream), "file", "MyFile.txt" } }; var response = await HttpClient.PostAsync(uri, content, cancellationToken); |
FileController.cs |
[HttpPost] public async Task<IActionResult> ImportAsync( [FromForm] IFormFile file, CancellationToken cancellationToken) { using var memoryStream = new MemoryStream(); await file.CopyToAsync(memoryStream, cancellationToken); byte[] b = memoryStream.ToArray(); } |
Request timeouts middleware
![]() |
To test in Visual Studio, run without the debugger. |
TaskController.cs |
[ApiController] [Route("[controller]")] public class TaskController : ControllerBase { [HttpPost("run")] [RequestTimeout(1000)] // 1000 ms [RequestTimeout("OneSecondPolicy")] public async Task RunTaskAsync(CancellationToken cancellationToken) { await Task.Delay(2000, cancellationToken); } } |
Program.cs |
builder.Services.AddRequestTimeouts(); builder.Services.AddRequestTimeouts(options => { options.DefaultPolicy = new RequestTimeoutPolicy { Timeout = TimeSpan.FromMilliseconds(1000) }; // default policy options.AddPolicy("OneSecondPolicy", TimeSpan.FromSeconds(1)); // named policy }); app.UseRequestTimeouts(); |
Background Task
It allows to return a response immediately while running a long background task.
Fire-and-Forget Background Task
ItemController.cs |
public class ItemController(IServiceScopeFactory scopeFactory) : ControllerBase { [HttpPost] public IActionResult StartLongRunningTask() { // start the task without waiting for the result Task.Run(async () => { // allow to resolve scoped services safely, // otherwise objects shared across queries will fail the app when they will be disposed using var scope = scopeFactory.CreateScope(); var itemService = scope.ServiceProvider.GetRequiredService<IItemService>(); await itemService.LongRunningProcessAsync(); }); return Ok("Task started successfully!"); // returns a response immediately without waiting } } |
.http file
http-client.env.json |
{ "dev": { "HostAddress": "http://localhost:8080" }, "prod": { "HostAddress": "http://items.domain.net" } } |
MyProject.http |
# Get an item by its id GET {{HostAddress}}/items/1 ### # Get tasks by request GET {{HostAddress}}/items?name=Item1 ### # Create a new item POST {{HostAddress}}/items Content-Type: application/json { "name": "ItemX" } ### # Update an existing item PUT {{HostAddress}}/items/1 Content-Type: application/json { "name": "Item X updated" } ### # Delete an item DELETE {{HostAddress}}/items/1 |
Erreurs
Unsupported Media Type with Postman
- Body → raw = json à envoyer
- Headers → Content-Type = application/json
JsonSerializationException: Self referencing loop detected for property 'xxx' ...
Startup.cs |
services.AddMvc() // ignorer les références circulaires entre objets dans EF pour JSON .AddJsonOptions(opt => opt.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore); |
SqlException: Cannot insert explicit value for identity column in table 'xxx' when IDENTITY_INSERT is set to OFF
Impossible de sauvegarder les changements si une entité a été insérée avec une valeur ≠ default dans une propriété bindée avec une colonne Identity.
Solution: forcer la valeur de cette propriété a default. La bdd mettra la valeur de la propriété a jour après avoir inséré l'entité.