« Asp.net core 8 web api » : différence entre les versions
Aucun résumé des modifications |
|||
(32 versions intermédiaires par le même utilisateur non affichées) | |||
Ligne 1 : | Ligne 1 : | ||
[[Category:.NET | [[Category:ASP.NET]] | ||
= Links = | = Links = | ||
* [[Swagger]] | * [[Swagger]] | ||
Ligne 11 : | Ligne 11 : | ||
= [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] = | ||
<filebox fn='Controllers/ItemsController.cs'> | <filebox fn='Controllers/ItemsController.cs'> | ||
[ApiController] | [ApiController] | ||
[Route("[controller]")] // /item | [Route("[controller]")] // /item | ||
Ligne 144 : | Ligne 143 : | ||
Response.Headers.Append("Key", "Value"); | Response.Headers.Append("Key", "Value"); | ||
</kode> | </kode> | ||
== [https://medium.com/@celery_liu/asp-net-core-web-api-with-swagger-api-versioning-for-dotnet-8-c8ce2fd7808c Version] == | |||
* [[Swagger#Version|Configure Swagger to handle version]] | |||
<filebox fn='Program.cs'> | |||
// nuget package Asp.Versioning.Mvc | |||
builder.Services | |||
.AddApiVersioning(options => | |||
{ | |||
options.ReportApiVersions = true; | |||
options.AssumeDefaultVersionWhenUnspecified = true; | |||
options.DefaultApiVersion = new ApiVersion(1, 0); | |||
}); | |||
</filebox> | |||
<filebox fn='ItemController.cs'> | |||
[ApiVersion("1.0")] | |||
[Route("v{version:apiVersion}/[controller]")] | |||
[ApiController] | |||
public class ItemController : ControllerBase | |||
{ } | |||
</filebox> | |||
= [https://learn.microsoft.com/en-us/aspnet/core/web-api/handle-errors?view=aspnetcore-7.0#problem-details-service Return exceptions as problem details response] = | = [https://learn.microsoft.com/en-us/aspnet/core/web-api/handle-errors?view=aspnetcore-7.0#problem-details-service Return exceptions as problem details response] = | ||
Ligne 238 : | Ligne 258 : | ||
</filebox> | </filebox> | ||
= Fluent Validation = | = [[Fluent_validation|Fluent Validation]] = | ||
* [https://github.com/FluentValidation/FluentValidation.AspNetCore GitHub] | * [https://github.com/FluentValidation/FluentValidation.AspNetCore GitHub] | ||
* [https://docs.fluentvalidation.net/en/latest/index.html Documentation] | * [https://docs.fluentvalidation.net/en/latest/index.html Documentation] | ||
Ligne 311 : | Ligne 331 : | ||
private static partial Regex kebabCaseRegex(); | private static partial Regex kebabCaseRegex(); | ||
public string TransformOutbound(object value) | public string? TransformOutbound(object? value) | ||
=> value == null ? null : kebabCaseRegex().Replace(value.ToString(), "$1-$2").ToLower(); | => value == null ? null : kebabCaseRegex().Replace(value.ToString(), "$1-$2").ToLower(); | ||
} | } | ||
Ligne 322 : | Ligne 342 : | ||
private const string PATH = "item"; | private const string PATH = "item"; | ||
private const string APIVERSION = "1"; | private const string APIVERSION = "1"; | ||
private readonly HttpClient httpClient; | private readonly HttpClient httpClient; | ||
Ligne 402 : | Ligne 420 : | ||
ErrorMessage = string.Empty; | ErrorMessage = string.Empty; | ||
Errors = errors; | Errors = errors; | ||
} | } | ||
} | } | ||
Ligne 452 : | Ligne 458 : | ||
== Dependency Injection == | == Dependency Injection == | ||
[https://learn.microsoft.com/en-us/dotnet/core/extensions/httpclient-factory#typed-clients Typed clients] are transient objects usually injected into services.<br> | |||
They are expected to be short-lived.<br> | |||
If a typed client instance is captured in a [https://learn.microsoft.com/en-us/dotnet/core/extensions/httpclient-factory#avoid-typed-clients-in-singleton-services singleton], it may prevent it from reacting to DNS changes, defeating one of the purposes of IHttpClientFactory. | |||
<filebox fn='Program.cs'> | <filebox fn='Program.cs'> | ||
services | services | ||
Ligne 517 : | Ligne 525 : | ||
</kode> | </kode> | ||
== | == Cache == | ||
= | <filebox fn='Program.cs'> | ||
< | services.AddMemoryCache(); // inject IMemoryCache | ||
public | </filebox> | ||
<filebox fn='MyClient.cs' collapsed> | |||
public class MyClient(HttpClient httpClient, IMemoryCache cache) : IMyClient | |||
{ | { | ||
private static readonly TimeSpan expiration = TimeSpan.FromHours(1); | |||
private async Task<Item[]> GetItemsAsync() | |||
{ | { | ||
var | 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; | |||
return | |||
} | } | ||
</filebox> | |||
== GET == | |||
<kode lang='cs'> | |||
// 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)) ?? []; | |||
</kode> | </kode> | ||
=== | === Json Converters === | ||
<kode | <kode> | ||
public | 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 | |||
} | } | ||
</kode> | |||
public | <filebox fn='JsonStringBoolConverter.cs' collapsed> | ||
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(), | |||
}; | |||
} | } | ||
</ | </filebox> | ||
== POST == | == POST == | ||
Ligne 636 : | Ligne 637 : | ||
response.EnsureSuccessStatusCode(); | response.EnsureSuccessStatusCode(); | ||
// var item = await response.Content. | // var item = await response.Content.ReadFromJsonAsync<ItemResponse>(cancellationToken); | ||
return new OperationStatus(); | return new OperationStatus(); | ||
Ligne 972 : | Ligne 973 : | ||
} | } | ||
</kode> | </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'> | |||
builder.Services | |||
.AddControllers(x => | |||
{ | |||
x.Filters.Add<MyFilter>(); | |||
}); | |||
</filebox> | |||
= Middleware = | |||
<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> | |||
= [https://github.com/gnaeus/OperationResult The Operation Result Pattern] = | = [https://github.com/gnaeus/OperationResult The Operation Result Pattern] = | ||
Ligne 1 150 : | Ligne 1 216 : | ||
} | } | ||
} | } | ||
</filebox> | |||
= [https://learn.microsoft.com/en-us/aspnet/core/performance/timeouts?view=aspnetcore-8.0 Request timeouts middleware] = | |||
{{warn | To test in Visual Studio, run without the debugger.}} | |||
<filebox fn='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); | |||
} | |||
} | |||
</filebox> | |||
<filebox fn='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(); | |||
</filebox> | </filebox> | ||
Dernière version du 6 avril 2025 à 17:28
Links
New projet
dotnet new webapi -o [project-name] --no-https |
![]() |
On VS to not open the web brower when you start the application: right-click on the project → Properties → Debug → uncheck Launch browser |
Controller
Controllers/ItemsController.cs |
[ApiController]
[Route("[controller]")] // /item
[Produces("application/json")] // inform about the output format, default is plain text (force le format JSON?)
public class ItemController(IItemService itemService) : ControllerBase
{ /* ... */ }
|
GET
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(ItemResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetByIdAsync(int id, CancellationToken cancellationToken)
{
var item = await context.Items
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
return item is null ? NotFound() : Ok(mapper.Map<ItemResponse>(item));
}
[HttpGet]
[ProducesResponseType(typeof(IReadOnlyCollection<ItemResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
public async Task<IActionResult> GetAsync([FromQuery] ItemQuery query, CancellationToken cancellationToken)
{
var predicate = PredicateBuilder.New<Item>(true);
if (!string.IsNullOrEmpty(query.Name))
{
predicate = predicate.And(x => EF.Functions.Like(x.Name, $"%{query.Name}%"));
}
if (query.Ids != null && query.Ids.Count > 0)
{
predicate = predicate.And(x => query.Ids.Contains(x.Id));
}
var items = await context.Items
.Where(predicate)
.AsNoTracking()
.ToListAsync(cancellationToken);
return Ok(mapper.Map<IReadOnlyCollection<ItemResponse>>(items));
}
|
POST
[HttpPost]
[ProducesResponseType(typeof(ItemResponse), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(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);
// 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);
var routeValues = new
{
controller = "Item", // name of the controller without "Controller", set only if it is a different one
action = nameof(ItemController.GetByIdAsync), // name of a controller method, optional
id = item.Id // set the id and all other needed parameters
};
return CreatedAtRoute(routeValues, createdResource);
}
|
![]() |
To handle Async suffix in CreatedAtAction
|
PUT / Update
[HttpPut("{id}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> UpdateAsync(int id, CreateUpdateItemQuery query)
{
var itemToUpdate = await context.Items.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (itemToUpdate is null)
{
return NotFound();
}
this.mapper.Map(query, itemToUpdate);
await context.SaveChangesAsync(cancellationToken);
return NoContent();
}
|
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
{ }
|
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 |
Controllers/ErrorController.cs |
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
Program.cs |
//var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddHealthChecks()
.AddDbContextCheck<MyAppContext>(); // add Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore package
//var app = builder.Build();
app.MapHealthChecks("/health");
|
HTTP Code | Description |
---|---|
200 | healthy |
503 | unhealthy |
CORS
Programs.cs |
// app.UseStaticFiles();
// fix access from origin '...' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource
// allow all origins
app.UseCors(x => x.SetIsOriginAllowed(origin => true));
// allow https://localhost:4200
app.UseCors(x => x.WithOrigins("https://localhost:4200"));
|
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();
queryBuilder.Add("api-version", APIVERSION);
}
|
Clients/OperationResult.cs |
Clients/OperationStatus.cs |
Extensions/QueryExtensions.cs |
Extensions/QueryBuilderExtensions.cs |
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
|
AAD OAuth2 JWT authentication
|
Cognito OAuth2 JWT authentication
|
Cache
Program.cs |
services.AddMemoryCache(); // inject IMemoryCache
|
MyClient.cs |
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 |
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 |
IClientBase.cs |
ClientBaseWithGetAll.cs |
IClientBaseWithGetAll.cs |
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 |
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 |
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();
}
|
Log
Add trace level for HttpClient, so you will get url and headers.
appsettings.json |
{
"Logging": {
"LogLevel": {
"System.Net.Http.HttpClient": "Trace"
}
}
}
|
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();
|
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é.