« Asp.net core 7 web api » : différence entre les versions
De Banane Atomic
Aller à la navigationAller à la recherche
(→GET) |
(→GET) |
||
Ligne 286 : | Ligne 286 : | ||
=== ReadAsAsync === | === ReadAsAsync === | ||
<kode lang='cs'> | <kode lang='cs'> | ||
public async Task<OperationResult<IReadOnlyCollection<ItemResponse>>> | private static IEnumerable<MediaTypeFormatter> problemJsonMediaTypeFormatters | ||
=> new MediaTypeFormatter[1] { new ProblemJsonMediaTypeFormatter() }; | |||
public async Task<OperationResult<IReadOnlyCollection<ItemResponse>>> GetItemsAsync(ItemQuery query, CancellationToken cancellationToken) | |||
{ | { | ||
ArgumentNullException.ThrowIfNull(query); | |||
var uri = new Uri($"{PATH}{queryBuilder.ToQueryString()}", UriKind.Relative); | var uri = new Uri($"{PATH}{queryBuilder.ToQueryString()}", UriKind.Relative); | ||
Ligne 308 : | Ligne 313 : | ||
var items = await response.Content.ReadAsAsync<IReadOnlyCollection<ItemResponse>>(cancellationToken); | var items = await response.Content.ReadAsAsync<IReadOnlyCollection<ItemResponse>>(cancellationToken); | ||
return new OperationResult<IReadOnlyCollection< | return new OperationResult<IReadOnlyCollection<ItemResponse>>(items); | ||
} | } | ||
</kode> | </kode> | ||
Ligne 336 : | Ligne 341 : | ||
Result = result; | Result = result; | ||
ValidationErrors = new Dictionary<string, string[]>(); | ValidationErrors = new Dictionary<string, string[]>(); | ||
} | |||
} | |||
</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); | |||
} | } | ||
} | } |
Version du 10 juillet 2023 à 22:56
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] [Produces("application/json")] // force le format JSON [ApiVersion("1")] // need nuget package Microsoft.AspNetCore.Mvc.Versioning [Route("[controller]")] // /item 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(ItemDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<IActionResult> GetItemByIdAsync(int id, CancellationToken cancellationToken) { if (id <= 0) { return BadRequest("id must be greater than 0"); } var item = await context.Items .AsNoTracking() .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); return transaction is null ? NotFound(id) : Ok(mapper.Map<ItemDto>(item)); } [HttpGet] [ProducesResponseType(typeof(IReadOnlyCollection<ItemDto>), StatusCodes.Status200OK)] public async Task<IActionResult> GetAsync([FromQuery] ItemQuery query, CancellationToken cancellationToken) { var predicate = PredicateBuilder.New<Item>(true); if (query.Name != default) { 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<ItemDto>>(items)); } // route: /[controller-route]/test [HttpGet("test")] public async Task<IActionResult> TestAsync() { } |
POST
[HttpPost] [ProducesResponseType(typeof(ItemDto), StatusCodes.Status201Created)] public async Task<IActionResult> CreateItemAsync(CreateUpdateItemCommand command, CancellationToken cancellationToken) { var item = this.mapper.Map<Item>(command); await context.AddAsync(item, cancellationToken); await context.SaveChangesAsync(cancellationToken); var response = this.mapper.Map<CreateItemResponse>(item); return Created(HttpContext.Request.Path, response); return Created($"https://localhost:5001/api/items/{createdItem.Id}", response); return CreatedAtAction(nameof(Get), new { id = createdItem.Id }, response); return CreatedAtRoute("api", new { controller = "items", action = nameof(Get), id = $"{response.Id}" }, response); } |
PUT / Update
[HttpPut("{id}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<IActionResult> UpdateAsync(int id, CreateUpdateItemCommand command) { if (id <= 0) { return BadRequest("id must be greater than 0"); } var itemToUpdate = await context.Transactions.FirstOrDefaultAsync(x => x.Id == id, cancellationToken) if (itemToUpdate == null) { return NotFound(); } this.mapper.Map(command, itemToUpdate); await context.SaveChangesAsync(cancellationToken); return NoContent(); } |
DELETE
[HttpDelete("{id:int}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<IActionResult> DeleteItemAsync(int id, CancellationToken cancellationToken) { if (id <= 0) { return BadRequest("id must be greater than 0."); } if (!await context.Transactions.AnyAsync(x => x.Id == id, cancellationToken)) { return NotFound(id); } context.Remove(new Item { Id = id }); await context.SaveChangesAsync(cancellationToken); return NoContent(); } |
Exception handler
In case of exception the media type returned is application/problem+json which is not handled by default on the client side. |
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 |
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); } |
GET
ReadAsAsync
private static IEnumerable<MediaTypeFormatter> problemJsonMediaTypeFormatters => new MediaTypeFormatter[1] { new ProblemJsonMediaTypeFormatter() }; public async Task<OperationResult<IReadOnlyCollection<ItemResponse>>> GetItemsAsync(ItemQuery query, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(query); var uri = new Uri($"{PATH}{queryBuilder.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); } |
Clients/OperationResult.cs |
public class OperationResult<T> { public T? Result { get; set; } public IDictionary<string, string[]> ValidationErrors { get; } // error messages grouped by property name public IReadOnlyCollection<string> Errors => ValidationErrors.SelectMany(x => x.Value).ToList(); public bool IsValid => ValidationErrors.Count == 0; public OperationResult(IDictionary<string, string[]> validationErrors) // for server side validation error { ValidationErrors = validationErrors; } public OperationResult(IEnumerable<ValidationFailure> validationFailures) // for client side validation errors { ValidationErrors = validationFailures .GroupBy(x => x.PropertyName, x => x.ErrorMessage) .ToDictionary(x => x.Key, x => x.ToArray()); } public OperationResult(T result) // for valid result { Result = result; ValidationErrors = new Dictionary<string, string[]>(); } } |
MediaTypeFormatters/ProblemJsonMediaTypeFormatter.cs |
public class ProblemJsonMediaTypeFormatter : JsonMediaTypeFormatter { private static readonly MediaTypeHeaderValue problemJsonMediaType = new("application/problem+json"); public ProblemJsonMediaTypeFormatter() { SupportedMediaTypes.Add(problemJsonMediaType); } } |
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}"); } } |
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 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
Startup.cs |
public void ConfigureServices(IServiceCollection services) { services.AddControllers(options => { options.ModelBinderProviders.Insert(0, new CommaSeparatedModelBinderProvider()); }); |
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 && 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) { 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; } } |
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é.