« Asp.net core web api » : différence entre les versions

De Banane Atomic
Aller à la navigationAller à la recherche
 
(49 versions intermédiaires par le même utilisateur non affichées)
Ligne 6 : Ligne 6 :
* [https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.http.statuscodes?view=aspnetcore-2.1 StatusCodes]
* [https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.http.statuscodes?view=aspnetcore-2.1 StatusCodes]
* [[Asp.net_core_mvc#Path|Path]]
* [[Asp.net_core_mvc#Path|Path]]
* [[Swagger]]
* [https://www.tutorialdocs.com/article/webapi-data-binding.html How to Implement Polymorphic Data Binding in .NET Core WebApi]
= New projet =
<kode lang='bash'>
dotnet new webapi -o [project-name] --no-https
</kode>


= Projet =
Clique-droit sur le projet → Properties → Debug → décocher Launch browser
Clique-droit sur le projet → Properties → Debug → décocher Launch browser


= [https://docs.microsoft.com/en-us/aspnet/core/tutorials/first-web-api?view=aspnetcore-3.1&tabs=visual-studio Controller] =
= [https://docs.microsoft.com/en-us/aspnet/core/tutorials/first-web-api?view=aspnetcore-3.1&tabs=visual-studio Controller] =
<filebox fn='Controllers/ItemsController.cs'>
<filebox fn='Controllers/ItemsController.cs'>
[ApiController]
[Produces("application/json")]  // force le format JSON
[Produces("application/json")]  // force le format JSON
[ApiVersion("1")]  // need nuget package Microsoft.AspNetCore.Mvc.Versioning
[ApiVersion("1")]  // need nuget package Microsoft.AspNetCore.Mvc.Versioning
[Route("api/[controller]")]    // /api/items
[Route("api/[controller]")]    // /api/items
[ApiController]
public class ItemsController : ControllerBase
public class ItemsController : ControllerBase
{
{
     private readonly IMyAppRepository _repository;
     private readonly IItemsRepository itemsRepository;
    private readonly IMapper mapper;


     public ListApiController(IMyAppRepository repository)
     public ItemsController(IItemsRepository itemsRepository, IMapper mapper)
     {
     {
         _repository = repository;
         this.itemsRepository = itemsRepository;
    }
        this.mapper = mapper;
</filebox>
</filebox>


Ligne 29 : Ligne 36 :
<kode lang='cs'>
<kode lang='cs'>
[HttpGet]
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<Item>))]
[ProducesResponseType(typeof(IReadOnlyCollection<ItemDto>), StatusCodes.Status200OK)]
public IActionResult Get() => Ok(_repository.GetAllItems());
public async Task<IActionResult> GetAsync([FromQuery] ItemQuery query)
{
    var predicate = PredicateBuilder.New<Item>(true);
    if (query.Ids != null && query.Ids.Count > 0)
    {
        predicate = predicate.And(x => query.Ids.Contains(x.Id));
    }
    if (query.Name != null)
    {
        predicate = predicate.And(x => EF.Functions.Like(x.Name, $"%{query.Name}%"));
    }
 
    var items = await context.Items.Where(predicate)
                                  .ToListAsync();
 
    var itemsDto = mapper.Map<IReadOnlyCollection<ItemDto>>(items);
           
    return Ok(items);
}


[HttpGet("{id:int}")]
[HttpGet("{id:int}")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(Item))]
[ProducesResponseType(typeof(ItemDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public IActionResult Get(int id)
public async Task<IActionResult> GetByIdAsync(int id)
{
{
     try
     try
     {
     {
         var item = _repository.Get(id);
        if (id <= 0)
        {
            return BadRequest();
        }
 
         var item = await context.FindAsync(id)
         if (item == null)
         if (item == null)
             return NotFound(); // status 404 Not Found
        {
             return NotFound(nameof(id));
        }


         return Ok(item); // status 200 OK
        var itemDto = mapper.Map<ItemDto>(item);
         return Ok(itemDto);
     }
     }
     catch (Exception ex)
     catch (Exception ex)
Ligne 52 : Ligne 86 :
     }
     }
}
}
// route: /[controller-route]/test
[HttpGet("test")]
public async Task<IActionResult> TestAsync() { }
</kode>
</kode>


Ligne 57 : Ligne 95 :
<kode lang='csharp'>
<kode lang='csharp'>
[HttpPost]
[HttpPost]
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(Item))]
[ProducesResponseType(typeof(ItemDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> CreateAsync(ItemDto itemDto)
public IActionResult Post([FromBody] Item item)
{
{
     //if (item == null)
     var item = this.mapper.Map<Item>(itemDto);
     //    return BadRequest();
     var createdItem = await itemsRepository.AddAsync(item);
 
     var createdItemDto = this.mapper.Map<ItemDto>(createdItem);
     //if (!ModelState.IsValid)
     return Created(HttpContext.Request.Path, createdItemDto);
     //    return BadRequest(ModelState);


    var createdItem = _itemRepository.Add(item);
     return Created($"https://localhost:5001/api/items/{createdItem.Id}", createdItemDto);
    return Created("item", createdItem);
     return CreatedAtAction(nameof(Get), new { id = createdItem.Id }, createdItemDto);
 
     return Created($"https://localhost:5001/api/items/{item.Id}", item);
     return CreatedAtAction(nameof(Get), new { id = item.Id }, item);
     return CreatedAtRoute("api", new {  
     return CreatedAtRoute("api", new {  
         controller =  "items",  
         controller =  "items",  
         action = nameof(Get),  
         action = nameof(Get),  
         id = $"{item.Id}"  
         id = $"{createdItem.Id}"  
     }, item);
     }, createdItemDto);
}
}
</kode>
</kode>
<filebox fn='Startup.cs'>
<filebox fn='Startup.cs'>
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
Ligne 89 : Ligne 123 :
</filebox>
</filebox>


== [https://docs.microsoft.com/en-us/aspnet/core/tutorials/first-web-api?view=aspnetcore-2.1 PUT / Update] ==
== [https://docs.microsoft.com/en-us/aspnet/core/tutorials/first-web-api PUT / Update] ==
<kode lang='csharp'>
<kode lang='csharp'>
[HttpPut("{id}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public IActionResult Put([FromBody] Item item)
public async Task<IActionResult> UpdateAsync(int id, ItemDto itemDto)
{
{
     //if (item == null)
     if (id <= 0)
     //    return BadRequest();
     {
        return BadRequest();
    }


     //if (!ModelState.IsValid)
     var itemToUpdate = await itemsRepository.GetByIdAsync(id);
     //    return BadRequest(ModelState);
    if (itemToUpdate == null)
     {
        return NotFound();
    }


     var itemToUpdate = _itemRepository.Get(item.Id);
     var item = this.mapper.Map<Item>(itemDto);
     if (itemToUpdate == null)
     await itemsRepository.UpdateAsync(itemToUpdate, item);
        return NotFound(); // status 404 Not Found


    _itemRepository.Update(item);
     return NoContent();
     return NoContent();
}
}
Ligne 117 : Ligne 155 :
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public IActionResult Delete(int id)
public async Task<IActionResult> DeleteAsync(int id)
{
{
     if (id <= 0)
     if (id <= 0)
Ligne 132 : Ligne 170 :
     }
     }


     var itemToDelete = _itemRepository.Get(id);
     var itemToDelete = await itemsRepository.GetByIdAsync(id);
     if (itemToDelete == null)
     if (itemToDelete == null)
         return NotFound();
         return NotFound();


     _itemRepository.Remove(id);
     await itemsRepository.DeleteAsync(id);
     return NoContent();
     return NoContent();
}
}
Ligne 174 : Ligne 212 :
     return items;
     return items;
}
}
</kode>
<kode lang='bash'>
dotnet add package Microsoft.AspNet.WebApi.Client
</kode>
</kode>


=== JsonSerializer.DeserializeAsync ===
=== JsonSerializer.DeserializeAsync ===
<kode lang='cs'>
<kode lang='cs' collapsed>
public async Task<IReadOnlyList<ItemDto>> GetAllItemsAsync()
public async Task<IReadOnlyList<ItemDto>> GetAllItemsAsync()
{
{
Ligne 282 : Ligne 323 :


{{info | Url: {{boxx|1=/api/items?api-version=1}}}}
{{info | Url: {{boxx|1=/api/items?api-version=1}}}}
= Header =
<filebox fn='Client/ItemClient.cs'>
this.httpClient.DefaultRequestHeaders.Add("ip", userIpAddress);
var response = await this.httpClient.PostAsync(uri, itemDtoJson);
</filebox>
<filebox fn='Controllers/ItemController.cs'>
[HttpPost]
public async Task<IActionResult> AddAsync(
    [FromBody] ItemDto itemDto,
    [FromHeader] string ip) // doesn't seem to work
{
    // work-around
    var ip = this.HttpContext.Request.Headers["ip"].SingleOrDefault();
}
</filebox>
= [https://docs.microsoft.com/en-us/aspnet/core/host-and-deploy/health-checks Health check] =
<filebox fn='Startup.cs'>
public void ConfigureServices(IServiceCollection services)
{
    services.AddHealthChecks();
    // ...
public void Configure(IApplicationBuilder app)
{
    app.UseEndpoints(endpoints =>
    {
        endpoints.MapHealthChecks("/health");
    });
    // ...
</filebox>


= [https://docs.microsoft.com/en-us/aspnet/core/mvc/models/validation Validation] =
= [https://docs.microsoft.com/en-us/aspnet/core/mvc/models/validation Validation] =
Ligne 335 : Ligne 409 :
}
}
</kode>
</kode>
<kode lang='cs'>
<kode lang='cs'>
// déserialiser la réponse
// déserialiser la réponse
var validationProblem = await JsonSerializer.DeserializeAsync<ValidationProblemDetails>(
var content = await response.Content.ReadAsStreamAsync();
    await response.Content.ReadAsStreamAsync());
var validationProblem = await JsonSerializer.DeserializeAsync<ValidationProblemDetails>(content);
</kode>
</kode>


= [https://docs.fluentvalidation.net/en/latest/aspnet.html Fluent Validation] =
= [https://docs.fluentvalidation.net/en/latest/aspnet.html Fluent Validation] =
<kode lang='bash'>
<kode lang='bash'>
dotnet add package FluentValidation.AspNetCore
dotnet add package FluentValidation.AspNetCore
Ligne 364 : Ligne 438 :
</filebox>
</filebox>


Status: 400 Bad Request
== Returns manually generated validation problems ==
<kode lang='json'>
<filebox fn='Controllers/ItemController.cs'>
public async Task<IActionResult> UpdateItemAsync(
    int id,
    CreateUpdateItemQuery query,
    CancellationToken cancellationToken)
{
    if (id <= 0)
    {
        var details = new ValidationProblemDetails
        {
            Title = "One or more validation errors occurred.",
            Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1"
        };
        details.Errors.Add(nameof(id), new string[] { $"'id' must be greater than 0. You set the id to {id}." });
        return ValidationProblem(details);  // BadRequestObjectResult with ValidationProblemDetails
 
        return Problem(
            detail: "id must be greater than 0.",              // error message
            statusCode: StatusCodes.Status400BadRequest,        // 500 by default
            title: "One or more validation errors occurred.");  // ObjectResult with ProblemDetails
 
        return BadRequest("id must be greater than 0.");  // BadRequestObjectResult with a string message
</filebox>
 
= Query Strings =
Url: {{boxx|1=http://localhost:80/api/items?ids=1,2&retrieveDeleted=true}}
 
<filebox fn='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);
</filebox>
 
<filebox fn='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
</filebox>
 
= Pagination =
<filebox fn='ItemQuery.cs'>
public class ItemQuery
{
{
     "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
     public string Name { get; set; }
     "title": "One or more validation errors occurred.",
     public int PageIndex { get; set; }
    "status": 400,
     public int PageSize { get; set; }
    "traceId": "|d471edd2-4872e538eb79bd17.",
     "errors": {
        "Name": [
            "'Name' must not be empty."
        ]
    }
}
}
</kode>
</filebox>
 
<filebox fn='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);
</filebox>


<filebox fn='FluentValidationIssueDto.cs'>
<filebox fn='ItemQueryExtension.cs'>
public class FluentValidationIssueDto
public static QueryString ToQueryString(this ItemQuery itemQuery)
{
{
     public string type { get; set; }
     var queryBuilder = new QueryBuilder();
     public string title { get; set; }
     queryBuilder.Add(nameof(itemQuery.ProfileName), itemQuery.Name);
     public int status { get; set; }
     queryBuilder.Add(nameof(itemQuery.PageIndex), itemQuery.PageIndex.ToString());
     public string traceId { get; set; }
     queryBuilder.Add(nameof(itemQuery.PageSize), itemQuery.PageSize.ToString());
     public IDictionary<string, IReadOnlyList<string>> errors { get; set; }
 
     return queryBuilder.ToQueryString();
}
}
</filebox>
</filebox>


= Query Strings =
= [[Dependency_injection|Dependency Injection]] =
Url: {{boxx|1=http://localhost:80/api/items?option1=true}}
<filebox fn='Program.cs'>
<filebox fn='ItemsController.cs'>
var builder = WebApplication.CreateBuilder(args);
[HttpGet]
 
public IActionResult Get(bool option1 = false) // par défaut à false
builder.Services.AddTransient<IMyService, MyService>();
{ }
</filebox>
</filebox>


Ligne 421 : Ligne 550 :
</filebox>
</filebox>


== Upload ==
== [https://docs.microsoft.com/en-us/aspnet/core/mvc/models/file-uploads Upload] ==
<filebox fn='MyController.cs'>
<filebox fn='MyController.cs'>
[HttpPost]
[HttpPost]
public async Task<IActionResult> Post([FromForm]IFormFile myFile)
public async Task<IActionResult> Post([FromForm]IFormFile formFile)
{
{
     var filePath = Path.Combine(folderPath, photo.FileName);
     var filePath = Path.Combine("/path/to/folder", formFile.FileName);
     using (var stream = new FileStream(filePath, FileMode.Create))
     using (var stream = new FileStream(filePath, FileMode.Create))
     {
     {
         await myFile.CopyToAsync(stream);
         await formFile.CopyToAsync(stream);
     }
     }


Ligne 436 : Ligne 565 :
</filebox>
</filebox>
Postman → Body → form-data
Postman → Body → form-data
* key: myFile
* key: formFile
* File
* File
* Value: Choose Files
* Value: Choose Files
{{warn | Si {{boxx|myFile}} est toujours {{boxx|null}}, enlever {{boxx|[FromForm]}}}}
{{warn | Si {{boxx|formFile}} est toujours {{boxx|null}}, enlever {{boxx|[FromForm]}}}}


= Renvoyer le message d'erreur des exceptions =
= Renvoyer le message d'erreur des exceptions =
Ligne 446 : Ligne 575 :
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
{
    // For Dev env
     if (env.IsDevelopment())
     if (env.IsDevelopment())
     {
     {
         //app.UseDeveloperExceptionPage();
         app.UseDeveloperExceptionPage(); // displays exception type, error message, call stack and headers
         app.UseExceptionHandler("/error-local-development");
         app.UseExceptionHandler("/error-development"); // custom
     }
     }
     else
     else
Ligne 458 : Ligne 588 :


<filebox fn='Controllers/ErrorController.cs'>
<filebox fn='Controllers/ErrorController.cs'>
[ApiExplorerSettings(IgnoreApi = true)] // exclude from Swagger / OpenAPI
[ApiController]
[ApiController]
public class ErrorController : ControllerBase
public class ErrorController : ControllerBase
{
{
     [Route("/error-local-development")]
     [Route("/error")]
    // default
    // "title": "An error occured while processing your request."
    // "status": 500, "traceId": "00-...-00"
    public IActionResult Error() => Problem();
    // additional properties
    public IActionResult Error()
    {
        var exceptionHandlerFeature = HttpContext.Features.Get<IExceptionHandlerFeature>()!;
        return Problem(detail: $"Exception type: {exceptionHandlerFeature.Error.GetType().Name}");
    }
 
    [Route("/error-development")]
     public IActionResult ErrorLocalDevelopment([FromServices] IWebHostEnvironment webHostEnvironment)
     public IActionResult ErrorLocalDevelopment([FromServices] IWebHostEnvironment webHostEnvironment)
     {
     {
         if (webHostEnvironment.EnvironmentName != "Development")
         if (!hostEnvironment.IsDevelopment())
         {
         {
             throw new InvalidOperationException("This shouldn't be invoked in non-development environments.");
             return NotFound();
         }
         }


         var context = HttpContext.Features.Get<IExceptionHandlerFeature>();
         var exceptionHandlerFeature = HttpContext.Features.Get<IExceptionHandlerFeature>()!;


         return Problem(
         return Problem(
             title: $"{context.Error.GetType().Name}: {context.Error.Message}",
             detail: exceptionHandlerFeature.Error.StackTrace,
             detail: context.Error.StackTrace);
             title: exceptionHandlerFeature.Error.Message);
    }
 
    [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}");
     }
     }
}
}
Ligne 595 : Ligne 730 :
}
}
</kode>
</kode>
= [https://github.com/gnaeus/OperationResult The Operation Result Pattern] =
<filebox fn='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;
    }
}
</filebox>
<kode lang='csharp'>
OperationResult<MyDto, MyError> result;
if (/*...*/)
{
    result = new OperationResult<MyDto, MyError>(myError);
}
else
{
    result = new OperationResult<MyDto, MyError>(myDto);
}
return result;
</kode>
= [https://docs.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-migrate-from-newtonsoft-how-to JSON serialization: Newtonsoft.Json vs System.Text.Json] =
By default {{boxx|.NET Core 3.0}} uses [[JSON_et_CSharp#System.Text.Json|System.Text.Json]].
== Use Newtonsoft.Json ==
<kode lang='bash'>
dotnet add package Microsoft.AspNetCore.Mvc.NewtonsoftJson
</kode>
<filebox fn='Startup.cs'>
public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();
            .AddNewtonsoftJson();
</filebox>
= [https://docs.microsoft.com/en-us/aspnet/core/mvc/models/model-binding?view=aspnetcore-3.1 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]
<filebox fn='Startup.cs'>
public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers(options =>
    {
        options.ModelBinderProviders.Insert(0, new CommaSeparatedModelBinderProvider());
    });
</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
            && context.Metadata.ModelType != typeof(string))
        {
            return new BinderTypeModelBinder(typeof(CommaSeparatedModelBinder));
        }
        return null;
    }
}
</filebox>
<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>


= Erreurs =
= Erreurs =

Dernière version du 18 juillet 2023 à 00:30

Liens

New projet

Bash.svg
dotnet new webapi -o [project-name] --no-https

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

Cs.svg
[HttpGet]
[ProducesResponseType(typeof(IReadOnlyCollection<ItemDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetAsync([FromQuery] ItemQuery query)
{
    var predicate = PredicateBuilder.New<Item>(true);
    if (query.Ids != null && query.Ids.Count > 0)
    {
        predicate = predicate.And(x => query.Ids.Contains(x.Id));
    }
    if (query.Name != null)
    {
        predicate = predicate.And(x => EF.Functions.Like(x.Name, $"%{query.Name}%"));
    }

    var items = await context.Items.Where(predicate)
                                   .ToListAsync();

    var itemsDto = mapper.Map<IReadOnlyCollection<ItemDto>>(items);
            
    return Ok(items);
}

[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 context.FindAsync(id)
        if (item == null)
        {
            return NotFound(nameof(id));
        }

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

// route: /[controller-route]/test
[HttpGet("test")]
public async Task<IActionResult> TestAsync() { }

POST

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

Csharp.svg
[HttpPut("{id}")]
[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

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

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

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) // doesn't seem to work
{
    // work-around
    var ip = this.HttpContext.Request.Headers["ip"].SingleOrDefault();
}

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

Json.svg
{
    "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."
        ]
    }
}
Cs.svg
// déserialiser la réponse
var content = await response.Content.ReadAsStreamAsync();
var validationProblem = await JsonSerializer.DeserializeAsync<ValidationProblemDetails>(content);

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();

Returns manually generated validation problems

Controllers/ItemController.cs
public async Task<IActionResult> UpdateItemAsync(
    int id,
    CreateUpdateItemQuery query,
    CancellationToken cancellationToken)
{
    if (id <= 0)
    {
        var details = new ValidationProblemDetails
        {
            Title = "One or more validation errors occurred.",
            Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1"
        };
        details.Errors.Add(nameof(id), new string[] { $"'id' must be greater than 0. You set the id to {id}." });
        return ValidationProblem(details);  // BadRequestObjectResult with ValidationProblemDetails

        return Problem(
            detail: "id must be greater than 0.",               // error message
            statusCode: StatusCodes.Status400BadRequest,        // 500 by default
            title: "One or more validation errors occurred.");  // ObjectResult with ProblemDetails

        return BadRequest("id must be greater than 0.");  // BadRequestObjectResult with a string message

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();
}

Dependency Injection

Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.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)
{
    // For Dev env
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage(); // displays exception type, error message, call stack and headers
        app.UseExceptionHandler("/error-development"); // custom
    }
    else
    {
        app.UseExceptionHandler("/error");
    }
Controllers/ErrorController.cs
[ApiExplorerSettings(IgnoreApi = true)] // exclude from Swagger / OpenAPI
[ApiController]
public class ErrorController : ControllerBase
{
    [Route("/error")]
    // default
    // "title": "An error occured while processing your request."
    // "status": 500, "traceId": "00-...-00"
    public IActionResult Error() => Problem();
    // additional properties
    public IActionResult Error()
    {
        var exceptionHandlerFeature = HttpContext.Features.Get<IExceptionHandlerFeature>()!;
        return Problem(detail: $"Exception type: {exceptionHandlerFeature.Error.GetType().Name}");
    }

    [Route("/error-development")]
    public IActionResult ErrorLocalDevelopment([FromServices] IWebHostEnvironment webHostEnvironment)
    {
        if (!hostEnvironment.IsDevelopment())
        {
            return NotFound();
        }

        var exceptionHandlerFeature = HttpContext.Features.Get<IExceptionHandlerFeature>()!;

        return Problem(
            detail: exceptionHandlerFeature.Error.StackTrace,
            title: exceptionHandlerFeature.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();
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é.