« Asp.net core 7 web api » : différence entre les versions
De Banane Atomic
Aller à la navigationAller à la recherche
(81 versions intermédiaires par le même utilisateur non affichées) | |||
Ligne 1 : | Ligne 1 : | ||
[[Category:.NET Core]] | [[Category:.NET Core]] | ||
= Links = | = Links = | ||
* [[Swagger]] | |||
= New projet = | = New projet = | ||
Ligne 10 : | Ligne 11 : | ||
= [https://docs.microsoft.com/en-us/aspnet/core/tutorials/first-web-api?view=aspnetcore-7.0&tabs=visual-studio-code Controller] = | = [https://docs.microsoft.com/en-us/aspnet/core/tutorials/first-web-api?view=aspnetcore-7.0&tabs=visual-studio-code Controller] = | ||
<filebox fn='Controllers/ItemsController.cs'> | <filebox fn='Controllers/ItemsController.cs'> | ||
[ApiVersion("1")] // need nuget package Microsoft.AspNetCore.Mvc.Versioning | |||
[ApiController] | [ApiController] | ||
[ | [Route("[controller]")] // /item | ||
[ | [Produces("application/json")] // inform about the output format, default is plain text (force le format JSON?) | ||
public class ItemController : ControllerBase | public class ItemController : ControllerBase | ||
{ | { | ||
Ligne 28 : | Ligne 29 : | ||
<kode lang='cs'> | <kode lang='cs'> | ||
[HttpGet("{id:int}")] | [HttpGet("{id:int}")] | ||
[ProducesResponseType(typeof( | [ProducesResponseType(typeof(ItemResponse), StatusCodes.Status200OK)] | ||
[ProducesResponseType(StatusCodes.Status404NotFound)] | [ProducesResponseType(StatusCodes.Status404NotFound)] | ||
public async Task<IActionResult> | public async Task<IActionResult> GetByIdAsync(int id, CancellationToken cancellationToken) | ||
{ | { | ||
var item = await context.Items | var item = await context.Items | ||
.AsNoTracking() | .AsNoTracking() | ||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); | .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); | ||
return | return item is null ? NotFound() : Ok(mapper.Map<ItemResponse>(item)); | ||
} | } | ||
[HttpGet] | [HttpGet] | ||
[ProducesResponseType(typeof(IReadOnlyCollection< | [ProducesResponseType(typeof(IReadOnlyCollection<ItemResponse>), StatusCodes.Status200OK)] | ||
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)] | |||
public async Task<IActionResult> GetAsync([FromQuery] ItemQuery query, CancellationToken cancellationToken) | public async Task<IActionResult> GetAsync([FromQuery] ItemQuery query, CancellationToken cancellationToken) | ||
{ | { | ||
var predicate = PredicateBuilder.New<Item>(); | var predicate = PredicateBuilder.New<Item>(true); | ||
if (query.Name | if (!string.IsNullOrEmpty(query.Name)) | ||
{ | { | ||
predicate = predicate.And(x => EF.Functions.Like(x.Name, $"%{query.Name}%")); | predicate = predicate.And(x => EF.Functions.Like(x.Name, $"%{query.Name}%")); | ||
Ligne 64 : | Ligne 60 : | ||
.ToListAsync(cancellationToken); | .ToListAsync(cancellationToken); | ||
return Ok(mapper.Map<IReadOnlyCollection< | return Ok(mapper.Map<IReadOnlyCollection<ItemResponse>>(items)); | ||
} | } | ||
</kode> | </kode> | ||
Ligne 75 : | Ligne 67 : | ||
<kode lang='csharp'> | <kode lang='csharp'> | ||
[HttpPost] | [HttpPost] | ||
[ProducesResponseType(typeof( | [ProducesResponseType(typeof(ItemResponse), StatusCodes.Status201Created)] | ||
public async Task<IActionResult> | [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)] | ||
public async Task<IActionResult> CreateAsync(CreateUpdateItemQuery query, CancellationToken cancellationToken) | |||
{ | { | ||
var item = this.mapper.Map<Item>( | var item = this.mapper.Map<Item>(query); | ||
await context.AddAsync(item, cancellationToken); | await context.AddAsync(item, cancellationToken); | ||
await context.SaveChangesAsync(cancellationToken); | await context.SaveChangesAsync(cancellationToken); | ||
var response = this.mapper.Map< | var response = this.mapper.Map<ItemResponse>(item); | ||
return Created( | |||
// 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 | |||
controller = " | action = nameof(ItemController.GetByIdAsync), // name of a controller method, optional | ||
action = nameof( | id = item.Id // set the id and all other needed parameters | ||
id = | }; | ||
return CreatedAtRoute(routeValues, createdResource); | |||
} | } | ||
</kode> | </kode> | ||
* [https://ochzhen.com/blog/created-createdataction-createdatroute-methods-explained-aspnet-core Created, CreatedAtAction, CreatedAtRoute methods] | |||
{{warn | | |||
To handle Async suffix in CreatedAtAction | |||
<filebox fn='Program.cs'> | |||
builder.Services.AddControllers(options => | |||
{ | |||
options.SuppressAsyncSuffixInActionNames = false; | |||
}); | |||
</filebox> | |||
}} | |||
== [https://docs.microsoft.com/en-us/aspnet/core/tutorials/first-web-api PUT / Update] == | == [https://docs.microsoft.com/en-us/aspnet/core/tutorials/first-web-api PUT / Update] == | ||
Ligne 99 : | Ligne 110 : | ||
[HttpPut("{id}")] | [HttpPut("{id}")] | ||
[ProducesResponseType(StatusCodes.Status204NoContent)] | [ProducesResponseType(StatusCodes.Status204NoContent)] | ||
[ProducesResponseType(StatusCodes.Status400BadRequest)] | [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)] | ||
[ProducesResponseType(StatusCodes.Status404NotFound)] | [ProducesResponseType(StatusCodes.Status404NotFound)] | ||
public async Task<IActionResult> UpdateAsync(int id, | public async Task<IActionResult> UpdateAsync(int id, CreateUpdateItemQuery query) | ||
{ | { | ||
var itemToUpdate = await context.Items.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); | |||
if (itemToUpdate is null) | |||
var itemToUpdate = await context. | |||
if (itemToUpdate | |||
{ | { | ||
return NotFound(); | return NotFound(); | ||
} | } | ||
this.mapper.Map( | this.mapper.Map(query, itemToUpdate); | ||
await context.SaveChangesAsync(cancellationToken); | await context.SaveChangesAsync(cancellationToken); | ||
Ligne 125 : | Ligne 131 : | ||
[HttpDelete("{id:int}")] | [HttpDelete("{id:int}")] | ||
[ProducesResponseType(StatusCodes.Status204NoContent)] | [ProducesResponseType(StatusCodes.Status204NoContent)] | ||
[ProducesResponseType(StatusCodes.Status404NotFound)] | [ProducesResponseType(StatusCodes.Status404NotFound)] | ||
public async Task<IActionResult> | public async Task<IActionResult> DeleteAsync(int id, CancellationToken cancellationToken) | ||
{ | { | ||
if (id | if (!await context.Items.AnyAsync(x => x.Id == id, cancellationToken)) | ||
{ | { | ||
return NotFound(); | |||
return NotFound( | |||
} | } | ||
Ligne 145 : | Ligne 145 : | ||
</kode> | </kode> | ||
= [https://learn.microsoft.com/en-us/aspnet/core/web-api/handle-errors?view=aspnetcore-7.0# | = [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] = | ||
{{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.}} | |||
<filebox fn='Program.cs'> | <filebox fn='Program.cs'> | ||
var app = builder.Build(); | //builder.Services.AddControllers(); | ||
builder.Services.AddProblemDetails(); | |||
//var app = builder.Build(); | |||
app.UseExceptionHandler(); | |||
app.UseStatusCodePages(); | |||
if (app.Environment.IsDevelopment()) | |||
{ | |||
app.UseDeveloperExceptionPage(); | |||
} | |||
//app.MapControllers(); | |||
//app.Run(); | |||
</filebox> | |||
== [https://learn.microsoft.com/en-us/aspnet/core/web-api/handle-errors?view=aspnetcore-7.0#exception-handler OLD Exception handler] == | |||
<filebox fn='Program.cs' collapsed> | |||
//var app = builder.Build(); | |||
if (app.Environment.IsDevelopment()) | if (app.Environment.IsDevelopment()) | ||
Ligne 158 : | Ligne 181 : | ||
} | } | ||
app.UseAuthorization(); | //app.UseAuthorization(); | ||
</filebox> | </filebox> | ||
<filebox fn='Controllers/ErrorController.cs'> | <filebox fn='Controllers/ErrorController.cs' collapsed> | ||
[ApiController] | [ApiController] | ||
public class ErrorController : ControllerBase | public class ErrorController : ControllerBase | ||
Ligne 182 : | Ligne 205 : | ||
return Problem( | return Problem( | ||
detail: exceptionHandlerFeature.Error.StackTrace, | detail: exceptionHandlerFeature.Error.StackTrace, | ||
title: exceptionHandlerFeature.Error.Message); | title: GetFinalInnerException(exceptionHandlerFeature.Error).Message); | ||
static Exception GetFinalInnerException(Exception exception) | |||
=> exception.InnerException is null ? exception : GetFinalInnerException(exception.InnerException); | |||
} | } | ||
} | } | ||
</filebox> | |||
== Handle application/problem+json on the client side == | |||
<filebox fn='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); | |||
</filebox> | |||
<filebox fn='MediaTypeFormatters/ProblemJsonMediaTypeFormatter.cs'> | |||
public class ProblemJsonMediaTypeFormatter : JsonMediaTypeFormatter | |||
{ | |||
private static readonly MediaTypeHeaderValue problemJsonMediaType = new("application/problem+json"); | |||
public ProblemJsonMediaTypeFormatter() | |||
{ | |||
SupportedMediaTypes.Add(problemJsonMediaType); | |||
} | |||
} | |||
</filebox> | |||
= Fluent Validation = | |||
* [https://github.com/FluentValidation/FluentValidation.AspNetCore GitHub] | |||
* [https://docs.fluentvalidation.net/en/latest/index.html Documentation] | |||
<kode lang='bash'> | |||
dotnet add package FluentValidation.AspNetCore | |||
</kode> | |||
<filebox fn='Program.cs'> | |||
//builder.Services.AddControllers(); | |||
builder.Services.AddFluentValidationAutoValidation(); | |||
builder.Services.AddValidatorsFromAssemblyContaining<Program>(); | |||
</filebox> | |||
<filebox fn='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 | |||
</filebox> | |||
= [https://learn.microsoft.com/en-us/aspnet/core/host-and-deploy/health-checks?view=aspnetcore-7.0 Health check] = | |||
<filebox fn='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"); | |||
</filebox> | |||
{| class="wikitable wtp" | |||
! HTTP Code | |||
! Description | |||
|- | |||
| 200 || healthy | |||
|- | |||
| 503 || unhealthy | |||
|} | |||
= CORS = | |||
<filebox fn='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")); | |||
</filebox> | </filebox> | ||
Ligne 191 : | Ligne 300 : | ||
public class ItemClient : IItemClient | public class ItemClient : IItemClient | ||
{ | { | ||
private const string | private const string PATH = "item"; | ||
private const string | private const string APIVERSION = "1"; | ||
protected static IEnumerable<MediaTypeFormatter> problemJsonMediaTypeFormatters | |||
=> new MediaTypeFormatter[1] { new ProblemJsonMediaTypeFormatter() }; | |||
private readonly HttpClient httpClient; | private readonly HttpClient httpClient; | ||
Ligne 198 : | Ligne 310 : | ||
// injection de HttpClient | // injection de HttpClient | ||
public | public ItemClient(HttpClient httpClient) | ||
{ | { | ||
this.httpClient = httpClient; | this.httpClient = httpClient; | ||
queryBuilder = new QueryBuilder(); | queryBuilder = new QueryBuilder(); | ||
queryBuilder.Add("api-version", | queryBuilder.Add("api-version", APIVERSION); | ||
} | |||
</filebox> | |||
<filebox fn='Clients/OperationResult.cs' collapsed> | |||
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[]>(); | |||
} | |||
} | |||
</filebox> | |||
<filebox fn='Clients/OperationStatus.cs' collapsed> | |||
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; | |||
} | |||
} | |||
</filebox> | |||
<filebox fn='MediaTypeFormatters/ProblemJsonMediaTypeFormatter.cs' collapsed> | |||
public class ProblemJsonMediaTypeFormatter : JsonMediaTypeFormatter | |||
{ | |||
private static readonly MediaTypeHeaderValue problemJsonMediaType = new("application/problem+json"); | |||
public ProblemJsonMediaTypeFormatter() | |||
{ | |||
SupportedMediaTypes.Add(problemJsonMediaType); | |||
} | |||
} | |||
</filebox> | |||
<filebox fn='Extensions/QueryExtensions.cs' collapsed> | |||
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(); | |||
} | |||
} | |||
</filebox> | |||
<filebox fn='Extensions/QueryBuilderExtensions.cs' collapsed> | |||
public static class QueryBuilderExtensions | |||
{ | |||
public static void AddIfValueNotNullNorEmpty(this QueryBuilder source, string key, string value) | |||
{ | |||
if (string.IsNullOrEmpty(value)) | |||
{ | |||
return; | |||
} | |||
source.Add(key, value); | |||
} | |||
} | |||
</filebox> | |||
== Dependency Injection == | |||
<filebox fn='Program.cs'> | |||
services | |||
.AddHttpClient<IMyClient, MyClient>(x => | |||
{ | |||
var authenticationString = $"{username}:{password}"; | |||
var base64EncodedAuthenticationString = Convert.ToBase64String(Encoding.ASCII.GetBytes(authenticationString)); | |||
x.BaseAddress = new Uri("https://www.address.net"); | |||
x.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", base64EncodedAuthenticationString); | |||
x.DefaultRequestHeaders.Add("User-Agent", "My App"); | |||
}) | |||
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler | |||
{ | |||
Proxy = new WebProxy | |||
{ | |||
Address = new Uri($"http://localhost:9000"), | |||
BypassProxyOnLocal = false, | |||
UseDefaultCredentials = false, | |||
}, | |||
}); | |||
</filebox> | </filebox> | ||
Ligne 210 : | Ligne 458 : | ||
=== ReadAsAsync === | === ReadAsAsync === | ||
<kode lang='cs'> | <kode lang='cs'> | ||
public async Task< | public async Task<OperationResult<IReadOnlyCollection<ItemResponse>>> GetAsync(FetchItemQuery query, CancellationToken cancellationToken) | ||
{ | |||
ArgumentNullException.ThrowIfNull(query); | |||
var uri = new Uri($"{PATH}{query.ToQueryString()}", UriKind.Relative); | |||
var response = await httpClient.GetAsync(uri, cancellationToken); | |||
// handle validation errors from the web API server | |||
if (response.StatusCode == System.Net.HttpStatusCode.BadRequest) | |||
{ | |||
var validationProblemDetails = | |||
await response.Content.ReadAsAsync<ValidationProblemDetails>( | |||
problemJsonMediaTypeFormatters, cancellationToken); | |||
return validationProblemDetails is null | |||
? throw new InvalidDataException($"{nameof(validationProblemDetails)} is null") | |||
: new OperationResult<IReadOnlyCollection<ItemResponse>>(validationProblemDetails.Errors); | |||
} | |||
response.EnsureSuccessStatusCode(); | |||
var items = await response.Content.ReadAsAsync<IReadOnlyCollection<ItemResponse>>(cancellationToken); | |||
return new OperationResult<IReadOnlyCollection<ItemResponse>>(items); | |||
} | |||
public async Task<OperationResult<TResponse>> GetByIdAsync(int id, CancellationToken cancellationToken) | |||
{ | { | ||
var uri = new Uri($"{ | var uri = new Uri($"{PATH}/{id}", UriKind.Relative); | ||
var response = await httpClient.GetAsync(uri, cancellationToken); | |||
if (response.StatusCode == HttpStatusCode.NotFound) | |||
{ | |||
return new OperationResult<TResponse>($"Item {id} has not been found."); | |||
} | |||
response.EnsureSuccessStatusCode(); | response.EnsureSuccessStatusCode(); | ||
var | var item = await response.Content.ReadAsAsync<ItemResponse>(cancellationToken); | ||
return | |||
return new OperationResult<ItemResponse>(item); | |||
} | } | ||
</kode> | </kode> | ||
<kode lang='bash'> | <kode lang='bash'> | ||
dotnet add package Microsoft.AspNet.WebApi.Client | dotnet add package Microsoft.AspNet.WebApi.Client | ||
Ligne 268 : | Ligne 550 : | ||
== POST == | == POST == | ||
<kode lang='cs'> | <kode lang='cs'> | ||
public async Task< | public async Task<OperationStatus> CreateAsync(CreateUpdateItemQuery query) | ||
{ | { | ||
ArgumentNullException.ThrowIfNull(query); | |||
var | var uri = new Uri(PATH, UriKind.Relative); | ||
var queryJson = new StringContent(JsonSerializer.Serialize(query), Encoding.UTF8, "application/json"); | |||
if (response. | 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); | |||
} | } | ||
return | response.EnsureSuccessStatusCode(); | ||
// var item = await response.Content.ReadAsAsync<ItemResponse>(cancellationToken); | |||
return new OperationStatus(); | |||
</kode> | </kode> | ||
== PUT == | == PUT == | ||
<kode lang='cs'> | <kode lang='cs'> | ||
public async Task | public async Task<OperationStatus> UpdateAsync(int id, CreateUpdateItemQuery query, CancellationToken cancellationToken) | ||
{ | { | ||
var | 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 | var response = await httpClient.PutAsync(uri, queryJson, cancellationToken); | ||
if ( | 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(); | |||
</kode> | </kode> | ||
== DELETE == | == DELETE == | ||
<kode lang='cs'> | <kode lang='cs'> | ||
public async Task | public async Task<OperationStatus> DeleteAsync(int id, CancellationToken cancellationToken) | ||
{ | { | ||
var response = await | var uri = new Uri($"{Path}/{id}", UriKind.Relative); | ||
if ( | 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 | 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(); | |||
</kode> | </kode> | ||
= | = ClientBase = | ||
<filebox fn=' | ClientBase for | ||
public | * GetById | ||
* GetAll | |||
* Create | |||
* Update | |||
* Delete | |||
<filebox fn='ItemClient.cs'> | |||
public class ItemClient : | |||
ClientBaseWithGetAll<ItemResponse, CreateUpdateItemQuery>, IItemClient | |||
{ | { | ||
protected override string Path => "item"; | |||
public | public ItemClient(HttpClient httpClient) | ||
: base(httpClient) | |||
{ } | |||
} | |||
} | |||
</filebox> | </filebox> | ||
<filebox fn='IItemClient.cs'> | |||
<filebox fn=' | public interface IItemClient : | ||
public | IClientBaseWithGetAll<ItemResponse, CreateUpdateItemQuery> | ||
{ } | |||
} | |||
</filebox> | </filebox> | ||
< | <filebox fn='ClientBase.cs' collapsed> | ||
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 | var uri = new Uri($"{Path}/{id}", UriKind.Relative); | ||
return | |||
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) | |||
{ | { | ||
return | 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(); | |||
} | } | ||
} | } | ||
</ | </filebox> | ||
<filebox fn='IClientBase.cs' collapsed> | |||
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); | ||
} | |||
</filebox> | |||
<filebox fn=' | <filebox fn='ClientBaseWithGetAll.cs' collapsed> | ||
public | 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; | |||
} | |||
} | |||
</filebox> | </filebox> | ||
<filebox fn=' | <filebox fn='IClientBaseWithGetAll.cs' collapsed> | ||
public | public interface IClientBaseWithGetAll<TResponse, TCreateUpdateQuery> : | ||
IClientBase<TResponse, TCreateUpdateQuery> | |||
{ | { | ||
Task<IReadOnlyCollection<TResponse>> GetAllAsync(CancellationToken cancellationToken); | |||
} | |||
</filebox> | </filebox> | ||
Ligne 465 : | Ligne 887 : | ||
return queryBuilder.ToQueryString(); | return queryBuilder.ToQueryString(); | ||
} | } | ||
</filebox> | </filebox> | ||
Ligne 564 : | Ligne 941 : | ||
return result; | return result; | ||
</kode> | |||
= JSON name mapping = | |||
<kode lang='cs'> | |||
[JsonPropertyName("otherName")] | |||
public string Property1 { get; set; } | |||
</kode> | </kode> | ||
Ligne 584 : | Ligne 967 : | ||
* [https://stackoverflow.com/questions/9584573/model-binding-comma-separated-query-string-parameter CommaSeparatedModelBinder] | * [https://stackoverflow.com/questions/9584573/model-binding-comma-separated-query-string-parameter CommaSeparatedModelBinder] | ||
<filebox fn=' | <filebox fn='Program.cs'> | ||
builder.Services.AddControllers(options => | |||
{ | { | ||
options.ModelBinderProviders.Insert(0, new CommaSeparatedModelBinderProvider()); | |||
}); | |||
</filebox> | </filebox> | ||
Ligne 596 : | Ligne 977 : | ||
public class CommaSeparatedModelBinderProvider : IModelBinderProvider | public class CommaSeparatedModelBinderProvider : IModelBinderProvider | ||
{ | { | ||
public IModelBinder GetBinder(ModelBinderProviderContext context) | public IModelBinder? GetBinder(ModelBinderProviderContext context) | ||
{ | { | ||
ArgumentNullException.ThrowIfNull(context); | |||
if (context.Metadata.ModelType.GetInterface(nameof(IEnumerable)) != null | if (context.Metadata.ModelType.GetInterface(nameof(IEnumerable)) != null | ||
Ligne 617 : | Ligne 995 : | ||
public class CommaSeparatedModelBinder : IModelBinder | public class CommaSeparatedModelBinder : IModelBinder | ||
{ | { | ||
private static readonly MethodInfo ToArrayMethod = typeof(Enumerable).GetMethod("ToArray"); | private static readonly MethodInfo ToArrayMethod = typeof(Enumerable).GetMethod("ToArray")!; | ||
public Task BindModelAsync(ModelBindingContext bindingContext) | public Task BindModelAsync(ModelBindingContext bindingContext) | ||
{ | { | ||
ArgumentNullException.ThrowIfNull(bindingContext); | |||
var modelType = bindingContext.ModelType; | var modelType = bindingContext.ModelType; | ||
Ligne 652 : | Ligne 1 027 : | ||
} | } | ||
var list = (IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(valueType)); | var list = (IList?)Activator.CreateInstance(typeof(List<>).MakeGenericType(valueType)); | ||
if (list is not null) | |||
{ | { | ||
foreach (var splitValue in value.Split(commaSeparator)) | |||
{ | { | ||
list.Add(Convert.ChangeType(splitValue, valueType)); | if (!string.IsNullOrWhiteSpace(splitValue)) | ||
{ | |||
list.Add(Convert.ChangeType(splitValue, valueType)); | |||
} | |||
} | } | ||
} | } | ||
Ligne 670 : | Ligne 1 048 : | ||
return Task.CompletedTask; | return Task.CompletedTask; | ||
} | } | ||
} | |||
</filebox> | |||
= [https://brokul.dev/sending-files-and-additional-data-using-httpclient-in-net-core Handle files] = | |||
<filebox fn='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); | |||
</filebox> | |||
<filebox fn='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(); | |||
} | |||
</filebox> | |||
= Kebab case = | |||
<filebox fn='Program.cs'> | |||
builder.Services | |||
.AddControllers(x => | |||
x.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> | </filebox> |
Dernière version du 10 juin 2024 à 12:48
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 |
[ApiVersion("1")] // need nuget package Microsoft.AspNetCore.Mvc.Versioning [ApiController] [Route("[controller]")] // /item [Produces("application/json")] // inform about the output format, default is plain text (force le format JSON?) public class ItemController : ControllerBase { private readonly IItemRepository itemRepository; private readonly IMapper mapper; public ItemsController(IItemRepository itemRepository, IMapper mapper) { this.itemRepository = itemRepository; this.mapper = mapper; |
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(); } |
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
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")); |
Client
Client/ItemClient.cs |
public class ItemClient : IItemClient { private const string PATH = "item"; private const string APIVERSION = "1"; protected static IEnumerable<MediaTypeFormatter> problemJsonMediaTypeFormatters => new MediaTypeFormatter[1] { new ProblemJsonMediaTypeFormatter() }; 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 |
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; } } |
MediaTypeFormatters/ProblemJsonMediaTypeFormatter.cs |
public class ProblemJsonMediaTypeFormatter : JsonMediaTypeFormatter { private static readonly MediaTypeHeaderValue problemJsonMediaType = new("application/problem+json"); public ProblemJsonMediaTypeFormatter() { SupportedMediaTypes.Add(problemJsonMediaType); } } |
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(); } } |
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
Program.cs |
services .AddHttpClient<IMyClient, MyClient>(x => { var authenticationString = $"{username}:{password}"; var base64EncodedAuthenticationString = Convert.ToBase64String(Encoding.ASCII.GetBytes(authenticationString)); x.BaseAddress = new Uri("https://www.address.net"); x.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", base64EncodedAuthenticationString); x.DefaultRequestHeaders.Add("User-Agent", "My App"); }) .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { Proxy = new WebProxy { Address = new Uri($"http://localhost:9000"), BypassProxyOnLocal = false, UseDefaultCredentials = false, }, }); |
GET
ReadAsAsync
public async Task<OperationResult<IReadOnlyCollection<ItemResponse>>> GetAsync(FetchItemQuery query, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(query); var uri = new Uri($"{PATH}{query.ToQueryString()}", UriKind.Relative); var response = await httpClient.GetAsync(uri, cancellationToken); // handle validation errors from the web API server if (response.StatusCode == System.Net.HttpStatusCode.BadRequest) { var validationProblemDetails = await response.Content.ReadAsAsync<ValidationProblemDetails>( problemJsonMediaTypeFormatters, cancellationToken); return validationProblemDetails is null ? throw new InvalidDataException($"{nameof(validationProblemDetails)} is null") : new OperationResult<IReadOnlyCollection<ItemResponse>>(validationProblemDetails.Errors); } response.EnsureSuccessStatusCode(); var items = await response.Content.ReadAsAsync<IReadOnlyCollection<ItemResponse>>(cancellationToken); return new OperationResult<IReadOnlyCollection<ItemResponse>>(items); } 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>($"Item {id} has not been found."); } response.EnsureSuccessStatusCode(); var item = await response.Content.ReadAsAsync<ItemResponse>(cancellationToken); return new OperationResult<ItemResponse>(item); } |
dotnet add package Microsoft.AspNet.WebApi.Client |
JsonSerializer.DeserializeAsync
public async Task<IReadOnlyList<ItemDto>> GetAllItemsAsync() { return await JsonSerializer.DeserializeAsync<IEnumerable<Item>>( await _httpClient.GetStreamAsync("api/item"), new JsonSerializerOptions() { PropertyNameCaseInsensitive = true }); var response = await _httpClient.GetAsync("api/item"); if (!response.IsSuccessStatusCode) throw new HttpRequestException(response.ReasonPhrase); return await JsonSerializer.DeserializeAsync<IEnumerable<Item>>( await response.Content.ReadAsStreamAsync(), new JsonSerializerOptions() { PropertyNameCaseInsensitive = true }); } public async Task<Item> GetItemAsync(int itemId) { return await JsonSerializer.DeserializeAsync<Item>( await _httpClient.GetStreamAsync($"api/item/{itemId}"), new JsonSerializerOptions() { PropertyNameCaseInsensitive = true }); var response = await _httpClient.GetAsync($"item/{itemId}"); if (!response.IsSuccessStatusCode) throw new HttpRequestException(response.ReasonPhrase); return await JsonSerializer.DeserializeAsync<Item>( await response.Content.ReadAsStreamAsync()); } |
POST
public async Task<OperationStatus> CreateAsync(CreateUpdateItemQuery query) { 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); 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.ReadAsAsync<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}"; } |
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(); } |
Kebab case
Program.cs |
builder.Services .AddControllers(x => x.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(); } |
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é.