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

De Banane Atomic
Aller à la navigationAller à la recherche
Ligne 387 : Ligne 387 :
     public string traceId { get; set; }
     public string traceId { get; set; }
     public IDictionary<string, IReadOnlyList<string>> errors { get; set; }
     public IDictionary<string, IReadOnlyList<string>> errors { get; set; }
}
</filebox>
<filebox fn='ItemClient.cs'>
if (response.StatusCode == HttpStatusCode.BadRequest)
{
    // doesn't work because
    // No MediaTypeFormatter is available to read an object of type 'FluentValidationIssueDto' from content with media type 'application/problem+json'
    var validationIssues = await response.Content.ReadAsAsync<FluentValidationIssueDto>();
    var content = await response.Content.ReadAsStreamAsync();
    var fluentValidationIssueDto = await JsonSerializer.DeserializeAsync<FluentValidationIssueDto>(content);
}
}
</filebox>
</filebox>

Version du 20 mars 2020 à 00:01

Liens

Projet

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

Controller

Controllers/ItemsController.cs
[Produces("application/json")]  // force le format JSON
[ApiVersion("1")]  // need nuget package Microsoft.AspNetCore.Mvc.Versioning
[Route("api/[controller]")]     // /api/items
[ApiController]
public class ItemsController : ControllerBase
{
    private readonly IMyAppRepository _repository;

    public ListApiController(IMyAppRepository repository)
    {
        _repository = repository;
    }

GET

Cs.svg
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<Item>))]
public IActionResult Get() => Ok(_repository.GetAllItems());

[HttpGet("{id:int}")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(Item))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public IActionResult Get(int id)
{
    try
    {
        var item = _repository.Get(id);
        if (item == null)
            return NotFound();  // status 404 Not Found

        return Ok(item);  // status 200 OK
    }
    catch (Exception ex)
    {
        // using Microsoft.AspNetCore.Http;
        return StatusCode(StatusCodes.Status500InternalServerError, $"Failed to get Item {id} ({ex})");
    }
}

POST

Csharp.svg
[HttpPost]
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(Item))]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public IActionResult Post([FromBody] Item item)
{
    //if (item == null)
    //    return BadRequest();

    //if (!ModelState.IsValid)
    //    return BadRequest(ModelState);

    var createdItem = _itemRepository.Add(item);
    return Created("item", createdItem);

    return Created($"https://localhost:5001/api/items/{item.Id}", item);
    return CreatedAtAction(nameof(Get), new { id = item.Id }, item);
    return CreatedAtRoute("api", new { 
        controller =  "items", 
        action = nameof(Get), 
        id = $"{item.Id}" 
    }, item);
}
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
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public IActionResult Put([FromBody] Item item)
{
    //if (item == null)
    //    return BadRequest();

    //if (!ModelState.IsValid)
    //    return BadRequest(ModelState);

    var itemToUpdate = _itemRepository.Get(item.Id);
    if (itemToUpdate == null)
        return NotFound();  // status 404 Not Found

    _itemRepository.Update(item);
    return NoContent();
}

DELETE

Cs.svg
[HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public IActionResult Delete(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 = _itemRepository.Get(id);
    if (itemToDelete == null)
        return NotFound();

    _itemRepository.Remove(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;
}

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

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 validationProblem = await JsonSerializer.DeserializeAsync<ValidationProblemDetails>(
    await response.Content.ReadAsStreamAsync());

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

Status: 400 Bad Request

Json.svg
{
    "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
    "title": "One or more validation errors occurred.",
    "status": 400,
    "traceId": "|d471edd2-4872e538eb79bd17.",
    "errors": {
        "Name": [
            "'Name' must not be empty."
        ]
    }
}
FluentValidationIssueDto.cs
public class FluentValidationIssueDto
{
    public string type { get; set; }
    public string title { get; set; }
    public int status { get; set; }
    public string traceId { get; set; }
    public IDictionary<string, IReadOnlyList<string>> errors { get; set; }
}
ItemClient.cs
if (response.StatusCode == HttpStatusCode.BadRequest)
{
    // doesn't work because
    // No MediaTypeFormatter is available to read an object of type 'FluentValidationIssueDto' from content with media type 'application/problem+json'
    var validationIssues = await response.Content.ReadAsAsync<FluentValidationIssueDto>();

    var content = await response.Content.ReadAsStreamAsync();
    var fluentValidationIssueDto = await JsonSerializer.DeserializeAsync<FluentValidationIssueDto>(content);
}

Query Strings

Url: http://localhost:80/api/items?option1=true

ItemsController.cs
[HttpGet]
public IActionResult Get(bool option1 = false)  // par défaut à false
{ }

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 myFile)
{
    var filePath = Path.Combine(folderPath, photo.FileName);
    using (var stream = new FileStream(filePath, FileMode.Create))
    {
        await myFile.CopyToAsync(stream);
    }

    return Ok(filePath);
}

Postman → Body → form-data

  • key: myFile
  • File
  • Value: Choose Files
Si myFile 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();
Cs.svg
[HttpGet("/error/{code:int}")]
public string Error(int code)
{
    return $"Error: {code}";
}

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