Asp.net core 7 web api

De Banane Atomic
Aller à la navigationAller à la recherche

Links

New projet

Bash.svg
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

Cs.svg
[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>();
    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

Csharp.svg
[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

Csharp.svg
[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

Cs.svg
[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

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: exceptionHandlerFeature.Error.Message);
    }
}

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

Cs.svg
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;
}
Bash.svg
dotnet add package Microsoft.AspNet.WebApi.Client

JsonSerializer.DeserializeAsync

Cs.svg
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

Cs.svg
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

Cs.svg
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

Cs.svg
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}");
    }
}

Health check

Startup.cs
public void ConfigureServices(IServiceCollection services)
{
    services.AddHealthChecks();
    // ...

public void Configure(IApplicationBuilder app)
{
    app.UseEndpoints(endpoints =>
    {
        endpoints.MapHealthChecks("/health");
    });
    // ...

Fluent Validation

Bash.svg
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

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();
Cs.svg
[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;
    }
}
Csharp.svg
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

Bash.svg
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é.