« Asp.net core 9 web api » : différence entre les versions
(85 versions intermédiaires par le même utilisateur non affichées) | |||
Ligne 1 : | Ligne 1 : | ||
[[Category:.NET Core]] | [[Category:.NET Core]] | ||
= Links = | |||
*[https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/overview?view=aspnetcore-9.0 Minimal APIs] | |||
= New projet = | = New projet = | ||
<kode lang='bash'> | <kode lang='bash'> | ||
dotnet new webapi -o [project-name] --no-https | dotnet new webapi -o [project-name] --no-https | ||
</kode> | </kode> | ||
{{info | On VS | |||
= 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]] | |||
= Debug launch profiles / launchSettings = | |||
{{info | On VS: right-click on the project → Properties → Debug → General → Open debug launch profiles UI}} | |||
<filebox fn='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" | |||
} | |||
</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> | |||
= [https://learn.microsoft.com/en-us/aspnet/core/tutorials/first-web-api?view=aspnetcore-8.0&tabs=visual-studio Controller] = | = [https://learn.microsoft.com/en-us/aspnet/core/tutorials/first-web-api?view=aspnetcore-8.0&tabs=visual-studio Controller] = | ||
Ligne 10 : | Ligne 72 : | ||
[ApiController] | [ApiController] | ||
[Route("[controller]")] // /item | [Route("[controller]")] // /item | ||
[Produces("application/json")] // inform about the output format, default | [Produces("application/json")] // inform about the output format, default are text/json and application/json | ||
public class ItemController(IItemService itemService) : ControllerBase | public class ItemController(IItemService itemService) : ControllerBase | ||
{ /* ... */ } | { /* ... */ } | ||
Ligne 18 : | Ligne 80 : | ||
== GET == | == GET == | ||
=== By id === | |||
<kode lang='cs'> | <kode lang='cs'> | ||
[HttpGet("{id:int}")] | [HttpGet("{id:int}")] | ||
[ProducesResponseType( | [ProducesResponseType<ItemResponse>(StatusCodes.Status200OK)] | ||
[ProducesResponseType(StatusCodes.Status404NotFound)] | [ProducesResponseType(StatusCodes.Status404NotFound)] | ||
public async Task<IActionResult> GetByIdAsync(int id, CancellationToken cancellationToken) | public async Task<IActionResult> GetByIdAsync(int id, CancellationToken cancellationToken) | ||
Ligne 26 : | Ligne 89 : | ||
var item = await context.Items | var item = await context.Items | ||
.AsNoTracking() | .AsNoTracking() | ||
. | .FindAsync(id, cancellationToken); | ||
return item is null ? NotFound() : Ok(mapper.Map<ItemResponse>(item)); | return item is null ? NotFound() : Ok(mapper.Map<ItemResponse>(item)); | ||
} | } | ||
</kode> | |||
=== Search === | |||
<kode lang='cs'> | |||
[HttpGet] | [HttpGet] | ||
[ProducesResponseType | [ProducesResponseType<ItemResponse[]>(StatusCodes.Status200OK)] | ||
[ProducesResponseType( | [ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)] | ||
public async Task<IActionResult> GetAsync([FromQuery] | public async Task<IActionResult> GetAsync([FromQuery] GetItemsRequest getItemsRequest, CancellationToken cancellationToken) | ||
{ | { | ||
var predicate = PredicateBuilder.New<Item>(true); | var predicate = PredicateBuilder.New<Item>(true); | ||
if (!string.IsNullOrEmpty( | if (!string.IsNullOrEmpty(getItemsRequest.Name)) | ||
predicate = predicate.And(x => EF.Functions.Like(x.Name, $"%{getItemsRequest.Name}%")); | |||
predicate = predicate.And(x => EF.Functions.Like(x.Name, $"%{ | |||
if (getItemsRequest.Ids != null && getItemsRequest.Ids.Count > 0) | |||
if ( | predicate = predicate.And(x => getItemsRequest.Ids.Contains(x.Id)); | ||
predicate = predicate.And(x => | |||
var items = await context.Items | var items = await context.Items | ||
Ligne 51 : | Ligne 114 : | ||
.ToListAsync(cancellationToken); | .ToListAsync(cancellationToken); | ||
return Ok(mapper.Map | return Ok(mapper.Map<ItemResponse[]>(items)); | ||
} | } | ||
</kode> | </kode> | ||
Ligne 58 : | Ligne 121 : | ||
<kode lang='csharp'> | <kode lang='csharp'> | ||
[HttpPost] | [HttpPost] | ||
[ProducesResponseType( | [ProducesResponseType<ItemResponse>(StatusCodes.Status201Created)] | ||
[ProducesResponseType( | [ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)] | ||
public async Task<IActionResult> CreateAsync(CreateUpdateItemQuery query, CancellationToken cancellationToken) | public async Task<IActionResult> CreateAsync(CreateUpdateItemQuery query, CancellationToken cancellationToken) | ||
{ | { | ||
Ligne 68 : | Ligne 131 : | ||
var response = this.mapper.Map<ItemResponse>(item); | var response = this.mapper.Map<ItemResponse>(item); | ||
// uri to the newly created item | return CreatedAtRoute(new { response.Id }, response); | ||
/*// uri to the newly created item | |||
var uri = new Uri($"https://www.site.net/items/{createdItem.Id}"); | var uri = new Uri($"https://www.site.net/items/{createdItem.Id}"); | ||
return Created(uri, response); | return Created(uri, response); | ||
Ligne 76 : | Ligne 141 : | ||
var routeValues = new { id = item.Id }; // path and query parameters | var routeValues = new { id = item.Id }; // path and query parameters | ||
return CreatedAtAction(actionName, controllerName, routeValues, response); | return CreatedAtAction(actionName, controllerName, routeValues, response); | ||
return CreatedAtRoute(nameof(GetByIdAsync), new { id = item.Id }, createdResource); | |||
var routeValues = new | var routeValues = new | ||
{ | { | ||
controller = "Item", // name of the controller without "Controller" | // controller = "Item", // optional: name of the controller without "Controller" | ||
action = nameof( | action = nameof(GetByIdAsync), // name of a controller method | ||
id = item.Id // set the id and all other needed parameters | id = item.Id // set the id and all other needed parameters | ||
}; | }; | ||
return CreatedAtRoute(routeValues, createdResource); | return CreatedAtRoute(routeValues, createdResource);*/ | ||
} | } | ||
</kode> | </kode> | ||
Ligne 97 : | Ligne 164 : | ||
}} | }} | ||
== [https://docs.microsoft.com/en-us/aspnet/core/tutorials/first-web-api PUT | == [https://docs.microsoft.com/en-us/aspnet/core/tutorials/first-web-api PUT (replace)] == | ||
<kode lang='csharp'> | <kode lang='csharp'> | ||
[HttpPut("{id}")] | [HttpPut("{id:int}")] | ||
[ProducesResponseType(StatusCodes.Status204NoContent)] | [ProducesResponseType(StatusCodes.Status204NoContent)] | ||
[ProducesResponseType( | [ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)] | ||
[ProducesResponseType(StatusCodes.Status404NotFound)] | [ProducesResponseType(StatusCodes.Status404NotFound)] | ||
public async Task<IActionResult> UpdateAsync(int id, CreateUpdateItemQuery query) | public async Task<IActionResult> UpdateAsync(int id, CreateUpdateItemQuery query, CancellationToken cancellationToken) | ||
{ | { | ||
var itemToUpdate = await context.Items. | var itemToUpdate = await context.Items.FindAsync(id, cancellationToken); | ||
if (itemToUpdate is null) | if (itemToUpdate is null) | ||
return NotFound(); | |||
this.mapper.Map(query, itemToUpdate); | |||
await context.SaveChangesAsync(cancellationToken); | |||
return NoContent(); | |||
} | |||
</kode> | |||
== [https://learn.microsoft.com/en-us/aspnet/core/web-api/jsonpatch?view=aspnetcore-9.0 PATCH] == | |||
{{warn | The {{boxx|System.Text.Json}}-based input formatter doesn't support JSON Patch.<br> | |||
It is possible to add support for JSON Patch using {{boxx|Newtonsoft.Json}}, while leaving the other input and output formatters unchanged.}} | |||
* Install {{boxx|Microsoft.AspNetCore.Mvc.NewtonsoftJson}} nuget package | |||
<filebox fn='Program.cs' collapsed> | |||
// 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()); | |||
}); | |||
</filebox> | |||
<filebox fn='JsonPatchInputFormatter.cs' collapsed> | |||
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(); | |||
} | |||
} | |||
</filebox> | |||
<kode lang='cs'> | |||
[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(); | return NotFound(); | ||
itemPatchDoccument.ApplyTo(itemToUpdate, ModelState); | |||
if (!ModelState.IsValid) | |||
return BadRequest(ModelState); | |||
await context.SaveChangesAsync(cancellationToken); | await context.SaveChangesAsync(cancellationToken); | ||
Ligne 117 : | Ligne 241 : | ||
} | } | ||
</kode> | </kode> | ||
{{warn | 1=Ensure client header {{boxx|Content-Type}} = {{boxx|application/json-patch+json}}}} | |||
== DELETE == | == DELETE == | ||
Ligne 126 : | Ligne 252 : | ||
{ | { | ||
if (!await context.Items.AnyAsync(x => x.Id == id, cancellationToken)) | if (!await context.Items.AnyAsync(x => x.Id == id, cancellationToken)) | ||
return NotFound(); | return NotFound(); | ||
context.Remove(new Item { Id = id }); | context.Remove(new Item { Id = id }); | ||
Ligne 142 : | Ligne 266 : | ||
== [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] == | ||
* [[ | * [[Swashbuckle#Version|Configure Swagger to handle version]] | ||
<filebox fn='Program.cs'> | <filebox fn='Program.cs'> | ||
// nuget package Asp.Versioning.Mvc | // nuget package Asp.Versioning.Mvc | ||
Ligne 160 : | Ligne 284 : | ||
public class ItemController : ControllerBase | public class ItemController : ControllerBase | ||
{ } | { } | ||
</filebox> | |||
= 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'> | |||
[EndpointSummary("This is a summary.")] | |||
[EndpointDescription("This is a description.")] | |||
[HttpGet] | |||
public IActionResult Get([Description("This is a description.")] Request request) { } | |||
</filebox> | |||
== [https://learn.microsoft.com/en-us/aspnet/core/fundamentals/openapi/include-metadata?view=aspnetcore-9.0&tabs=controllers#enum enum] == | |||
<filebox fn=''> | |||
[JsonConverter(typeof(JsonStringEnumConverter<TaskStatus>))] | |||
public enum TaskStatus | |||
{ | |||
ReadyToStart, | |||
InProgress, | |||
Completed | |||
} | |||
</filebox> | |||
== [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'> | |||
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 | |||
) {} | |||
</filebox> | </filebox> | ||
Ligne 280 : | Ligne 438 : | ||
</filebox> | </filebox> | ||
= [https://learn.microsoft.com/en-us/aspnet/core/host-and-deploy/health-checks?view=aspnetcore- | = [https://learn.microsoft.com/en-us/aspnet/core/host-and-deploy/health-checks?view=aspnetcore-9.0 Health check] = | ||
* [https://learn.microsoft.com/en-us/dotnet/architecture/microservices/implement-resilient-applications/monitor-app-health Health monitoring] | |||
{{info | Add the nuget packages | |||
* {{boxx|Microsoft.Extensions.Diagnostics.HealthChecks}} | |||
* {{boxx|AspNetCore.HealthChecks.UI}} | |||
* {{boxx|AspNetCore.HealthChecks.UI.Client}} | |||
* {{boxx|AspNetCore.HealthChecks.UI.InMemory.Storage}}}} | |||
<filebox fn='Program.cs'> | <filebox fn='Program.cs'> | ||
//var builder = WebApplication.CreateBuilder(args); | //var builder = WebApplication.CreateBuilder(args); | ||
Ligne 286 : | Ligne 450 : | ||
builder.Services | builder.Services | ||
.AddHealthChecks() | .AddHealthChecks() | ||
.AddDbContextCheck<MyAppContext>() | .AddMongoDb(builder.Configuration["MongoConnectionStringKey"], failureStatus: HealthStatus.Unhealthy) // MongoDB with AspNetCore.HealthChecks.MongoDb nuget package | ||
.AddDbContextCheck<MyAppContext>() // EF | |||
.AddCheck<ExternalApiHealthCheck>("External API") | |||
//var app = builder.Build(); | //var app = builder.Build(); | ||
app.MapHealthChecks("/health"); | app.MapHealthChecks("/health", new HealthCheckOptions | ||
{ | |||
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse // JSON UI-friendly response | |||
}); | |||
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."); | |||
} | |||
} | |||
</filebox> | </filebox> | ||
Ligne 304 : | Ligne 485 : | ||
= CORS = | = CORS = | ||
<filebox fn='Programs.cs'> | <filebox fn='Programs.cs'> | ||
// allow all origins | // allow all origins | ||
app.UseCors(x => x.SetIsOriginAllowed( | app.UseCors(x => x.SetIsOriginAllowed(_ => true)); | ||
// allow https://localhost:4200 | // allow https://localhost:4200 | ||
app.UseCors(x => x.WithOrigins("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"); | |||
</filebox> | </filebox> | ||
Ligne 332 : | Ligne 523 : | ||
} | } | ||
</filebox> | </filebox> | ||
= Client = | = Client = | ||
Ligne 351 : | Ligne 539 : | ||
this.httpClient = httpClient; | this.httpClient = httpClient; | ||
queryBuilder = new QueryBuilder | queryBuilder = new QueryBuilder | ||
{ | |||
{ "api-version", APIVERSION } | |||
}; | |||
} | } | ||
</filebox> | </filebox> | ||
Ligne 437 : | Ligne 627 : | ||
queryBuilder.AddIfValueNotNullNorEmpty(nameof(query.MyProperty), query.MyProperty); | queryBuilder.AddIfValueNotNullNorEmpty(nameof(query.MyProperty), query.MyProperty); | ||
return queryBuilder.ToQueryString(); | return queryBuilder.ToQueryString(); // ?key1=value1,key2=value2 | ||
} | } | ||
} | } | ||
Ligne 1 203 : | Ligne 1 393 : | ||
byte[] b = memoryStream.ToArray(); | byte[] b = memoryStream.ToArray(); | ||
} | } | ||
</filebox> | </filebox> | ||
Ligne 1 244 : | Ligne 1 422 : | ||
app.UseRequestTimeouts(); | app.UseRequestTimeouts(); | ||
</filebox> | |||
= Background Task = | |||
It allows to return a response immediately while running a long background task. | |||
== Fire-and-Forget Background Task == | |||
<filebox fn='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 | |||
} | |||
} | |||
</filebox> | |||
= [https://learn.microsoft.com/en-us/aspnet/core/test/http-files?view=aspnetcore-9.0 .http file] = | |||
<filebox fn='http-client.env.json'> | |||
{ | |||
"dev": { | |||
"HostAddress": "http://localhost:8080" | |||
}, | |||
"prod": { | |||
"HostAddress": "http://items.domain.net" | |||
} | |||
} | |||
</filebox> | |||
<filebox fn='MyProject.http' lang='bash' collapsed> | |||
# 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 | |||
</filebox> | </filebox> | ||
Dernière version du 17 mars 2025 à 13:53
Links
New projet
dotnet new webapi -o [project-name] --no-https |
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:
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"
}
|
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);
}
|
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<ItemResponse>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> 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<ItemResponse[]>(StatusCodes.Status200OK)]
[ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> 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<ItemResponse>(StatusCodes.Status201Created)]
[ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> 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);
return CreatedAtRoute(new { response.Id }, response);
/*// uri to the newly created item
var uri = new Uri($"https://www.site.net/items/{createdItem.Id}");
return Created(uri, response);
var actionName = nameof(ItemController.GetByIdAsync); // default to controller method name. Tmo handle Async suffix see the warning box below
var controllerName = "Item"; // default to controller name. Don't add the 'Controller' suffix
var routeValues = new { id = item.Id }; // path and query parameters
return CreatedAtAction(actionName, controllerName, routeValues, response);
return CreatedAtRoute(nameof(GetByIdAsync), new { id = item.Id }, createdResource);
var routeValues = new
{
// controller = "Item", // optional: name of the controller without "Controller"
action = nameof(GetByIdAsync), // name of a controller method
id = item.Id // set the id and all other needed parameters
};
return CreatedAtRoute(routeValues, createdResource);*/
}
|
![]() |
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
Response.Headers.Append("Key", "Value");
|
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
{ }
|
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 problem details 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
|
Program.cs |
//var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddHealthChecks()
.AddMongoDb(builder.Configuration["MongoConnectionStringKey"], failureStatus: HealthStatus.Unhealthy) // MongoDB with AspNetCore.HealthChecks.MongoDb nuget package
.AddDbContextCheck<MyAppContext>() // EF
.AddCheck<ExternalApiHealthCheck>("External API")
//var app = builder.Build();
app.MapHealthChecks("/health", new HealthCheckOptions
{
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse // JSON UI-friendly response
});
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 |
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");
|
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();
}
|
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();
}
|
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>();
|
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é.