« Asp.net core web api » : différence entre les versions
Ligne 705 : | Ligne 705 : | ||
* [https://docs.microsoft.com/en-us/aspnet/core/mvc/advanced/custom-model-binding?view=aspnetcore-3.1 Custom Model Binding] | * [https://docs.microsoft.com/en-us/aspnet/core/mvc/advanced/custom-model-binding?view=aspnetcore-3.1 Custom Model Binding] | ||
* [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='CommaSeparatedModelBinder.cs' collapsed> | |||
public class CommaSeparatedModelBinder : IModelBinder | |||
{ | |||
private static readonly MethodInfo ToArrayMethod = typeof(Enumerable).GetMethod("ToArray"); | |||
public Task BindModelAsync(ModelBindingContext bindingContext) | |||
{ | |||
if (bindingContext == null) | |||
{ | |||
throw new ArgumentNullException(nameof(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)); | |||
foreach (var splitValue in value.Split(new[] { ',' })) | |||
{ | |||
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; | |||
} | |||
} | |||
</filebox> | |||
<filebox fn='CommaSeparatedModelBinderProvider.cs'> | |||
public class CommaSeparatedModelBinderProvider : IModelBinderProvider | |||
{ | |||
public IModelBinder GetBinder(ModelBinderProviderContext context) | |||
{ | |||
if (context == null) | |||
{ | |||
throw new ArgumentNullException(nameof(context)); | |||
} | |||
if (context.Metadata.ModelType.GetInterface(nameof(IEnumerable)) != null) | |||
{ | |||
return new BinderTypeModelBinder(typeof(CommaSeparatedModelBinder)); | |||
} | |||
return null; | |||
} | |||
} | |||
</filebox> | |||
<filebox fn='Startup.cs'> | |||
public void ConfigureServices(IServiceCollection services) | |||
{ | |||
services.AddControllers(options => | |||
{ | |||
options.ModelBinderProviders.Insert(0, new CommaSeparatedModelBinderProvider()); | |||
}); | |||
</filebox> | |||
= Erreurs = | = Erreurs = |
Version du 17 avril 2021 à 13:55
Liens
- Create web APIs with ASP.NET Core
- Build web APIs with ASP.NET CoreBuild web APIs with ASP.NET Core
- Controller action return types in ASP.NET Core Web API
- StatusCodes
- Path
- Swagger
Projet
Clique-droit sur le projet → Properties → Debug → décocher Launch browser
Controller
Controllers/ItemsController.cs |
[ApiController] [Produces("application/json")] // force le format JSON [ApiVersion("1")] // need nuget package Microsoft.AspNetCore.Mvc.Versioning [Route("api/[controller]")] // /api/items public class ItemsController : ControllerBase { private readonly IItemsRepository itemsRepository; private readonly IMapper mapper; public ItemsController(IItemsRepository itemsRepository, IMapper mapper) { this.itemsRepository = itemsRepository; this.mapper = mapper; |
GET
[HttpGet] [ProducesResponseType(typeof(IReadOnlyList<ItemDto>), StatusCodes.Status200OK)] public async Task<IActionResult> GetAllAsync() { var items = await itemsRepository.GetAllAsync() var itemDtos = mapper.Map<IReadOnlyList<ItemDto>>(items); return Ok(itemDtos); } [HttpGet("{id:int}")] [ProducesResponseType(typeof(ItemDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] public async Task<IActionResult> GetByIdAsync(int id) { try { if (id <= 0) { return BadRequest(); } var item = await itemsRepository.GetByIdAsync(id) if (item == null) { return NotFound(); } var itemDto = mapper.Map<ItemDto>(item); return Ok(itemDto); } catch (Exception ex) { // using Microsoft.AspNetCore.Http; return StatusCode(StatusCodes.Status500InternalServerError, $"Failed to get Item {id} ({ex})"); } } [HttpGet] [ProducesResponseType(typeof(IReadOnlyList<ItemDto>), StatusCodes.Status200OK)] public async Task<IActionResult> GetAsync([FromQuery] ItemQueryDto itemQueryDto) { var itemQuery = this.mapper.Map<ItemQuery>(itemQueryDto); var items = await itemsRepository.GetAsync(itemQuery); var itemDtos = this.mapper.Map<IReadOnlyList<ItemDto>>(items); return Ok(itemDtos); } |
POST
[HttpPost] [ProducesResponseType(typeof(ItemDto), StatusCodes.Status201Created)] public async Task<IActionResult> CreateAsync(ItemDto itemDto) { var item = this.mapper.Map<Item>(itemDto); var createdItem = await itemsRepository.AddAsync(item); var createdItemDto = this.mapper.Map<ItemDto>(createdItem); return Created(HttpContext.Request.Path, createdItemDto); return Created($"https://localhost:5001/api/items/{createdItem.Id}", createdItemDto); return CreatedAtAction(nameof(Get), new { id = createdItem.Id }, createdItemDto); return CreatedAtRoute("api", new { controller = "items", action = nameof(Get), id = $"{createdItem.Id}" }, createdItemDto); } |
Startup.cs |
public void Configure(IApplicationBuilder app, IHostingEnvironment env) { app.UseMvc(routes => { routes.MapRoute( name: "api", template: "api/{controller=Items}/{id?}"); }); |
PUT / Update
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<IActionResult> UpdateAsync(int id, ItemDto itemDto) { if (id <= 0) { return BadRequest(); } var itemToUpdate = await itemsRepository.GetByIdAsync(id); if (itemToUpdate == null) { return NotFound(); } var item = this.mapper.Map<Item>(itemDto); await itemsRepository.UpdateAsync(itemToUpdate, item); return NoContent(); } |
DELETE
[HttpDelete("{id:int}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<IActionResult> DeleteAsync(int id) { if (id <= 0) { // do not provide details return BadRequest(); // return a string instead of ProblemDetails return BadRequest("id must be greater than 0."); // same as BadRequest() but with detail return Problem( title: "Bad Request", detail: "id must be greater than 0.", statusCode: StatusCodes.Status400BadRequest); } var itemToDelete = await itemsRepository.GetByIdAsync(id); if (itemToDelete == null) return NotFound(); await itemsRepository.DeleteAsync(id); return NoContent(); } |
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 ItemDataService(HttpClient httpClient) { this.httpClient = httpClient; queryBuilder = new QueryBuilder(); queryBuilder.Add("api-version", ApiVersion); } |
GET
ReadAsAsync
public async Task<IReadOnlyList<ItemDto>> GetAllItemsAsync(CancellationToken cancellationToken) { var uri = new Uri($"{Path}{queryBuilder.ToQueryString()}", UriKind.Relative); var response = await this.httpClient.GetAsync(uri, cancellationToken); response.EnsureSuccessStatusCode(); var items = await response.Content.ReadAsAsync<IReadOnlyList<ItemDto>>(cancellationToken); return items; } |
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<Item> AddItemAsync(Item item) { var itemJson = new StringContent(JsonSerializer.Serialize(item), Encoding.UTF8, "application/json"); var response = await _httpClient.PostAsync("api/item", itemJson); if (response.IsSuccessStatusCode) { return await JsonSerializer.DeserializeAsync<Item>(await response.Content.ReadAsStreamAsync()); } return null; } |
PUT
public async Task UpdateItemAsync(Item item) { var itemJson = new StringContent(JsonSerializer.Serialize(item), Encoding.UTF8, "application/json"); var response = await _httpClient.PutAsync("api/item", itemJson); if (!response.IsSuccessStatusCode) { var validationProblem = await JsonSerializer.DeserializeAsync<ValidationProblemDetails>( await response.Content.ReadAsStreamAsync()); // validationProblem.Errors: key = property, values = error list } } |
DELETE
public async Task DeleteItemAsync(int itemId) { var response = await _httpClient.DeleteAsync($"api/item/{itemId}"); if (!response.IsSuccessStatusCode) { var validationProblem = await JsonSerializer.DeserializeAsync<ValidationProblemDetails>( await response.Content.ReadAsStreamAsync()); throw new HttpRequestException($"{validationProblem.Title}: {validationProblem.Detail}"); } } |
API versioning
Need nuget package Microsoft.AspNetCore.Mvc.Versioning |
Controllers/ItemsController.cs |
[ApiVersion("1")] [ApiController] public class ItemsController : ControllerBase |
Startup.cs |
public void ConfigureServices(IServiceCollection services) { services.AddControllers(); services.AddApiVersioning(); |
Url: /api/items?api-version=1 |
Header
Client/ItemClient.cs |
this.httpClient.DefaultRequestHeaders.Add("ip", userIpAddress); var response = await this.httpClient.PostAsync(uri, itemDtoJson); |
Controllers/ItemController.cs |
[HttpPost] public async Task<IActionResult> AddAsync( [FromBody] ItemDto itemDto, [FromHeader] string ip) { } |
Health check
Startup.cs |
public void ConfigureServices(IServiceCollection services) { services.AddHealthChecks(); // ... public void Configure(IApplicationBuilder app) { app.UseEndpoints(endpoints => { endpoints.MapHealthChecks("/health"); }); // ... |
Validation
ItemViewModel.cs |
public class ItemViewModel { public int Id { get; set; } [Required] [MinLength(6)] public string Name { get; set; } } |
[HttpPost] public IActionResult Post([FromBody]ItemViewModel itemVm) { // ce code n'est plus nécessaire car pris en charge par les automatic HTTP 400 responses if (!ModelState.IsValid) { return BadRequest(ModelState); } var item = itemVm.ToEfItem(); _repository.Add(item); if (_repository.SaveAll()) { var newItemVm = new ItemViewModel(item); return Created($"/api/ListApi/{newItemVm.Id}", newItemVm); } else { return BadRequest("Failed to save."); } |
Automatic HTTP 400 responses
L'attribut [ApiController] effectue automatiquement une validation du modèle et envoie une réponse HTTP 400 en cas d'erreur.
Il n'est donc plus nécessaire de tester ModelState.IsValid.
ASP.NET Core MVC utilise le filtre d'action ModelStateInvalidFilter pour tester la validité du modèle.
Exemple de réponse en cas d'erreur de validation du modèle:
{ "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1", "title": "One or more validation errors occurred.", "status": 400, "traceId": "|b659be6b-4fc34f5e9f3caf17.", "errors": { "Name": [ "The Name field is required." ] } } |
// déserialiser la réponse var content = await response.Content.ReadAsStreamAsync(); var validationProblem = await JsonSerializer.DeserializeAsync<ValidationProblemDetails>(content); |
Fluent Validation
dotnet add package FluentValidation.AspNetCore |
Startup.cs |
public void ConfigureServices(IServiceCollection services) { services.AddControllers() .AddFluentValidation(); services.AddTransient<IValidator<ItemDto>, ItemValidator>(); |
Validation/ItemValidator.cs |
public class ItemValidator : AbstractValidator<ItemDto> { public ItemValidator() { RuleFor(x => x.Name).NotEmpty(); |
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 |
Dependency Injection
Startup.cs |
public void ConfigureServices(IServiceCollection services) { services.AddControllers(); services.AddTransient<IMyService, MyService>(); |
Images
Download
MyController.cs |
[HttpGet] public IActionResult Get() { var filePath = Path.Combine("~", "folder", "image.jpeg"); // VirtualFileResult return File(filePath, "image/jpeg"); } |
Base64
MyController.cs |
[HttpGet] public IActionResult Get() { var filePath = Path.Combine("~", "folder", "image.jpeg"); byte[] bytesArray = System.IO.File.ReadAllBytes(filePath); return Ok("data:image/jpeg;base64," + Convert.ToBase64String(bytesArray)); } |
Upload
MyController.cs |
[HttpPost] public async Task<IActionResult> Post([FromForm]IFormFile formFile) { var filePath = Path.Combine("/path/to/folder", formFile.FileName); using (var stream = new FileStream(filePath, FileMode.Create)) { await formFile.CopyToAsync(stream); } return Ok(filePath); } |
Postman → Body → form-data
- key: formFile
- File
- Value: Choose Files
Si formFile est toujours null, enlever [FromForm] |
Renvoyer le message d'erreur des exceptions
Exception handler
Startup.cs |
public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { if (env.IsDevelopment()) { //app.UseDeveloperExceptionPage(); app.UseExceptionHandler("/error-local-development"); } else { app.UseExceptionHandler("/error"); } |
Controllers/ErrorController.cs |
[ApiController] public class ErrorController : ControllerBase { [Route("/error-local-development")] public IActionResult ErrorLocalDevelopment([FromServices] IWebHostEnvironment webHostEnvironment) { if (webHostEnvironment.EnvironmentName != "Development") { throw new InvalidOperationException("This shouldn't be invoked in non-development environments."); } var context = HttpContext.Features.Get<IExceptionHandlerFeature>(); return Problem( title: $"{context.Error.GetType().Name}: {context.Error.Message}", detail: context.Error.StackTrace); } [Route("/error")] public IActionResult Error() => Problem(); // "title": "An error occured while processing your request." public IActionResult Error() { var context = HttpContext.Features.Get<IExceptionHandlerFeature>(); return Problem(detail: $"{context.Error.GetType().Name}: {context.Error.Message}"); } } |
ExceptionFilterAttribute
ApiExceptionFilterAttribute.cs |
public class ApiExceptionFilterAttribute : ExceptionFilterAttribute { public override void OnException(ExceptionContext context) { context.HttpContext.Response.StatusCode = (int)HttpStatusCode.InternalServerError; context.HttpContext.Response.ContentType = "text/plain"; context.HttpContext.Response.WriteAsync(context.Exception.Message).ConfigureAwait(false); } } |
MyController.cs |
[ApiExceptionFilter] [Route("api/[controller]")] public class MyController : Controller |
ExceptionMiddleware
ExceptionMiddleware.cs |
public class ExceptionMiddleware { public async Task Invoke(HttpContext context) { context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; var ex = context.Features.Get<IExceptionHandlerFeature>()?.Error; if (ex == null) return; context.Response.ContentType = "text/plain"; await context.Response.WriteAsync(ex.Message); } } |
Startup.cs |
public void Configure(IApplicationBuilder app, IHostingEnvironment env) { app.UseExceptionHandler(new ExceptionHandlerOptions { ExceptionHandler = new ExceptionMiddleware().Invoke }); app.UseMvc(); |
Différencier la gestion d'erreur pour mvc et webapi
Utiliser un ApiExceptionFilterAttribute et un MvcExceptionFilter
UseWhen et UseExceptionHandler
Startup.cs |
public void Configure(IApplicationBuilder app, IHostingEnvironment env) { app.UseWhen(x => x.Request.Path.Value.StartsWith("/api"), builder => { builder.UseExceptionHandler(new ExceptionHandlerOptions { ExceptionHandler = new ExceptionMiddleware().Invoke }); }); app.UseWhen(x => !x.Request.Path.Value.StartsWith("/api"), builder => { builder.UseExceptionHandler("/Home/Error"); }); |
UseExceptionHandler
ExceptionMiddleware.cs |
public class ExceptionMiddleware { public async Task Invoke(HttpContext context) { if (context.Request.Path.Value.StartsWith("/api")) { context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; var ex = context.Features.Get<IExceptionHandlerFeature>()?.Error; if (ex == null) return; context.Response.ContentType = "text/plain"; await context.Response.WriteAsync(ex.Message).ConfigureAwait(false); } else { // redirect to /Home/Error } } } |
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 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
CommaSeparatedModelBinder.cs |
public class CommaSeparatedModelBinder : IModelBinder { private static readonly MethodInfo ToArrayMethod = typeof(Enumerable).GetMethod("ToArray"); public Task BindModelAsync(ModelBindingContext bindingContext) { if (bindingContext == null) { throw new ArgumentNullException(nameof(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)); foreach (var splitValue in value.Split(new[] { ',' })) { 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; } } |
CommaSeparatedModelBinderProvider.cs |
public class CommaSeparatedModelBinderProvider : IModelBinderProvider { public IModelBinder GetBinder(ModelBinderProviderContext context) { if (context == null) { throw new ArgumentNullException(nameof(context)); } if (context.Metadata.ModelType.GetInterface(nameof(IEnumerable)) != null) { return new BinderTypeModelBinder(typeof(CommaSeparatedModelBinder)); } return null; } } |
Startup.cs |
public void ConfigureServices(IServiceCollection services) { services.AddControllers(options => { options.ModelBinderProviders.Insert(0, new CommaSeparatedModelBinderProvider()); }); |
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é.