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

De Banane Atomic
Aller à la navigationAller à la recherche
 
(40 versions intermédiaires par le même utilisateur non affichées)
Ligne 1 : Ligne 1 :
[[Category:.NET Core]]
[[Category:.NET Core]]
= Links =
= Links =
* [[Swagger]]


= New projet =
= New projet =
Ligne 10 : Ligne 11 :
= [https://docs.microsoft.com/en-us/aspnet/core/tutorials/first-web-api?view=aspnetcore-7.0&tabs=visual-studio-code Controller] =
= [https://docs.microsoft.com/en-us/aspnet/core/tutorials/first-web-api?view=aspnetcore-7.0&tabs=visual-studio-code Controller] =
<filebox fn='Controllers/ItemsController.cs'>
<filebox fn='Controllers/ItemsController.cs'>
[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
[ApiController]
[ApiController]
[Route("[controller]")]        // /item
[Route("[controller]")]        // /item
[Produces("application/json")]  // inform about the output format, default is plain text (force le format JSON?)
public class ItemController : ControllerBase
public class ItemController : ControllerBase
{
{
Ligne 75 : Ligne 76 :


     var response = this.mapper.Map<ItemResponse>(item);
     var response = this.mapper.Map<ItemResponse>(item);
    return Created(new Uri(HttpContext.Request.Path), response);


     return Created($"https://localhost:5001/api/items/{createdItem.Id}", response);
     // uri to the newly created item
     return CreatedAtAction(nameof(Get), new { id = createdItem.Id }, response);
    var uri = new Uri($"https://www.site.net/items/{createdItem.Id}");
     return CreatedAtRoute("api", new {  
    return Created(uri, response);
         controller =  "items",  
 
         action = nameof(Get),  
     var actionName = nameof(ItemController.GetByIdAsync);  // default to controller method name. Tmo handle Async suffix see the warning box below
         id = $"{response.Id}"
    var controllerName = "Item";  // default to controller name. Don't add the 'Controller' suffix
     }, response);
    var routeValues = new { id = item.Id }; // path and query parameters
    return CreatedAtAction(actionName, controllerName, routeValues, response);
 
     var routeValues = new
    {
         controller = "Item", // name of the controller without "Controller", set only if it is a different one
         action = nameof(ItemController.GetByIdAsync), // name of a controller method, optional
         id = item.Id // set the id and all other needed parameters
    };
     return CreatedAtRoute(routeValues, createdResource);
}
}
</kode>
</kode>
* [https://ochzhen.com/blog/created-createdataction-createdatroute-methods-explained-aspnet-core Created, CreatedAtAction, CreatedAtRoute methods]
{{warn |
To handle Async suffix in CreatedAtAction
<filebox fn='Program.cs'>
builder.Services.AddControllers(options =>
{
    options.SuppressAsyncSuffixInActionNames = false;
});
</filebox>
}}


== [https://docs.microsoft.com/en-us/aspnet/core/tutorials/first-web-api PUT / Update] ==
== [https://docs.microsoft.com/en-us/aspnet/core/tutorials/first-web-api PUT / Update] ==
Ligne 126 : Ligne 145 :
</kode>
</kode>


= [https://learn.microsoft.com/en-us/aspnet/core/web-api/handle-errors?view=aspnetcore-7.0#problem-details-service Handle exceptions with the problem details service] =
= [https://learn.microsoft.com/en-us/aspnet/core/web-api/handle-errors?view=aspnetcore-7.0#problem-details-service Return exceptions as problem details response] =
{{info | By default exceptions are returned in plain text.<br>With the problem details service it returns now a problem details response.}}
{{warn | In case of exception the media type returned is {{boxx|application/problem+json}} which is not handled by default on the client side.}}
{{warn | In case of exception the media type returned is {{boxx|application/problem+json}} which is not handled by default on the client side.}}


Ligne 264 : Ligne 284 :
| 503 || unhealthy
| 503 || unhealthy
|}
|}
= CORS =
<filebox fn='Programs.cs'>
// app.UseStaticFiles();
// fix access from origin '...' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource
// allow all origins
app.UseCors(x => x.SetIsOriginAllowed(origin => true));
// allow https://localhost:4200
app.UseCors(x => x.WithOrigins("https://localhost:4200"));
</filebox>


= Client =
= Client =
Ligne 271 : Ligne 302 :
     private const string PATH = "item";
     private const string PATH = "item";
     private const string APIVERSION = "1";
     private const string APIVERSION = "1";
    protected static IEnumerable<MediaTypeFormatter> problemJsonMediaTypeFormatters
        => new MediaTypeFormatter[1] { new ProblemJsonMediaTypeFormatter() };


     private readonly HttpClient httpClient;
     private readonly HttpClient httpClient;
Ligne 285 : Ligne 319 :
</filebox>
</filebox>


== GET ==
<filebox fn='Clients/OperationResult.cs' collapsed>
=== ReadAsAsync ===
public class OperationResult<T>
<kode lang='cs'>
private static IEnumerable<MediaTypeFormatter> problemJsonMediaTypeFormatters
    => new MediaTypeFormatter[1] { new ProblemJsonMediaTypeFormatter() };
 
public async Task<OperationResult<IReadOnlyCollection<ItemResponse>>> GetItemsAsync(ItemQuery query, CancellationToken cancellationToken)
{
{
     ArgumentNullException.ThrowIfNull(query);
     private readonly IDictionary<string, string[]> validationErrors;


     var uri = new Uri($"{PATH}{query.ToQueryString()}", UriKind.Relative);
     public T? Result { get; }
    public IReadOnlyCollection<string> ErrorMessages => validationErrors.SelectMany(x => x.Value).ToList();
    public bool IsValid => validationErrors.Count == 0;


     var response = await httpClient.GetAsync(uri, cancellationToken);
     // used to handle errors of server validation
    public OperationResult(IDictionary<string, string[]> validationErrors)
    {
        this.validationErrors = validationErrors;
    }


     // handle validation errors from the web API server
     // used to handle errros of client validation with FluentValidation
     if (response.StatusCode == System.Net.HttpStatusCode.BadRequest)
     public OperationResult(IEnumerable<ValidationFailure> validationFailures)
     {
     {
         var validationProblemDetails =
         validationErrors = validationFailures
             await response.Content.ReadAsAsync<ValidationProblemDetails>(
             .GroupBy(x => x.PropertyName, x => x.ErrorMessage)
                problemJsonMediaTypeFormatters, cancellationToken);
            .ToDictionary(x => x.Key, x => x.ToArray());
    }


        return validationProblemDetails is null
    // used to handle error
            ? throw new InvalidDataException($"{nameof(validationProblemDetails)} is null")
    public OperationResult(string errorMessage)
            : new OperationResult<IReadOnlyCollection<ItemResponse>>(validationProblemDetails.Errors);
    {
        validationErrors = new Dictionary<string, string[]> { { string.Empty, new string[] { errorMessage } } };
     }
     }


     response.EnsureSuccessStatusCode();
     // use to hanlde valid result
 
    public OperationResult(T result)
    var items = await response.Content.ReadAsAsync<IReadOnlyCollection<ItemResponse>>(cancellationToken);
    {
 
        Result = result;
    return new OperationResult<IReadOnlyCollection<ItemResponse>>(items);
        validationErrors = new Dictionary<string, string[]>();
    }
}
}
</kode>
</filebox>


<filebox fn='Clients/OperationResult.cs' collapsed>
<filebox fn='Clients/OperationStatus.cs' collapsed>
public class OperationResult<T>
public class OperationStatus
{
{
     public T? Result { get; set; }
     public string ErrorMessage { get; }
     public IDictionary<string, string[]> ValidationErrors { get; } // error messages grouped by property name
     public IDictionary<string, string[]> Errors { get; }
     public IReadOnlyCollection<string> Errors => ValidationErrors.SelectMany(x => x.Value).ToList();
     public bool Success => string.IsNullOrEmpty(ErrorMessage) && Errors.Count == 0;
    public bool IsValid => ValidationErrors.Count == 0;


     public OperationResult(IDictionary<string, string[]> validationErrors) // for server side validation error
    // use to hanlde valid status
     public OperationStatus()
     {
     {
         ValidationErrors = validationErrors;
         ErrorMessage = string.Empty;
        Errors = new Dictionary<string, string[]>();
     }
     }


     public OperationResult(IEnumerable<ValidationFailure> validationFailures) // for client side validation errors
    // used to handle single error of server validation
     public OperationStatus(string errorMessage)
     {
     {
         ValidationErrors = validationFailures
         ErrorMessage = errorMessage;
            .GroupBy(x => x.PropertyName, x => x.ErrorMessage)
        Errors = new Dictionary<string, string[]>();
            .ToDictionary(x => x.Key, x => x.ToArray());
     }
     }


     public OperationResult(T result) // for valid result
    // used to handle errors of server validation
     public OperationStatus(IDictionary<string, string[]> errors)
     {
     {
         Result = result;
         ErrorMessage = string.Empty;
         ValidationErrors = new Dictionary<string, string[]>();
         Errors = errors;
     }
     }
}
}
Ligne 362 : Ligne 402 :
public static class QueryExtensions
public static class QueryExtensions
{
{
     public static QueryString ToQueryString(this ItemQuery query)
     public static QueryString ToQueryString(this FetchItemQuery query)
     {
     {
         var queryBuilder = new QueryBuilder
         var queryBuilder = new QueryBuilder
Ligne 391 : Ligne 431 :
}
}
</filebox>
</filebox>
== Dependency Injection ==
<filebox fn='Program.cs'>
services
    .AddHttpClient<IMyClient, MyClient>(x =>
    {
        var authenticationString = $"{username}:{password}";
        var base64EncodedAuthenticationString = Convert.ToBase64String(Encoding.ASCII.GetBytes(authenticationString));
        x.BaseAddress = new Uri("https://www.address.net");
        x.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", base64EncodedAuthenticationString);
        x.DefaultRequestHeaders.Add("User-Agent", "My App");
    })
    .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
    {
        Proxy = new WebProxy
        {
            Address = new Uri($"http://localhost:9000"),
            BypassProxyOnLocal = false,
            UseDefaultCredentials = false,
        },
    });
</filebox>
== GET ==
=== ReadAsAsync ===
<kode lang='cs'>
public async Task<OperationResult<IReadOnlyCollection<ItemResponse>>> GetAsync(FetchItemQuery query, CancellationToken cancellationToken)
{
    ArgumentNullException.ThrowIfNull(query);
    var uri = new Uri($"{PATH}{query.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);
}
public async Task<OperationResult<TResponse>> GetByIdAsync(int id, CancellationToken cancellationToken)
{
    var uri = new Uri($"{PATH}/{id}", UriKind.Relative);
    var response = await httpClient.GetAsync(uri, cancellationToken);
    if (response.StatusCode == HttpStatusCode.NotFound)
    {
        return new OperationResult<TResponse>($"Item {id} has not been found.");
    }
    response.EnsureSuccessStatusCode();
    var item = await response.Content.ReadAsAsync<ItemResponse>(cancellationToken);
    return new OperationResult<ItemResponse>(item);
}
</kode>


<kode lang='bash'>
<kode lang='bash'>
Ligne 439 : Ligne 550 :
== POST ==
== POST ==
<kode lang='cs'>
<kode lang='cs'>
public async Task<Item> AddItemAsync(Item item)
public async Task<OperationStatus> CreateAsync(CreateUpdateItemQuery query)
{
{
     var itemJson =
     ArgumentNullException.ThrowIfNull(query);
        new StringContent(JsonSerializer.Serialize(item), Encoding.UTF8, "application/json");


     var response = await _httpClient.PostAsync("api/item", itemJson);
     var uri = new Uri(PATH, UriKind.Relative);
    var queryJson = new StringContent(JsonSerializer.Serialize(query), Encoding.UTF8, "application/json");


     if (response.IsSuccessStatusCode)
    var response = await httpClient.PostAsync(uri, queryJson);
     if (response.StatusCode == HttpStatusCode.BadRequest)
     {
     {
         return await JsonSerializer.DeserializeAsync<Item>(await response.Content.ReadAsStreamAsync());
         var validationProblemDetails =
            await response.Content.ReadAsAsync<ValidationProblemDetails>(
                problemJsonMediaTypeFormatters, cancellationToken);
 
        return validationProblemDetails is null
            ? throw new InvalidDataException($"{nameof(validationProblemDetails)} is null")
            : new OperationStatus(validationProblemDetails.Errors);
     }
     }


     return null;
    response.EnsureSuccessStatusCode();
}
 
    // var item = await response.Content.ReadAsAsync<ItemResponse>(cancellationToken);
 
     return new OperationStatus();
</kode>
</kode>


== PUT ==
== PUT ==
<kode lang='cs'>
<kode lang='cs'>
public async Task UpdateItemAsync(Item item)
public async Task<OperationStatus> UpdateAsync(int id, CreateUpdateItemQuery query, CancellationToken cancellationToken)
{
{
     var itemJson = new StringContent(JsonSerializer.Serialize(item), Encoding.UTF8, "application/json");
    ArgumentNullException.ThrowIfNull(query);
 
     var uri = new Uri($"{Path}/{id}", UriKind.Relative);
    var queryJson = new StringContent(JsonSerializer.Serialize(query), Encoding.UTF8, "application/json");
 
    var response = await httpClient.PutAsync(uri, queryJson, cancellationToken);
    if (response.StatusCode == HttpStatusCode.BadRequest)
    {
        var validationProblemDetails =
            await response.Content.ReadAsAsync<ValidationProblemDetails>(
                problemJsonMediaTypeFormatters, cancellationToken);
 
        return validationProblemDetails is null
            ? throw new InvalidDataException($"{nameof(validationProblemDetails)} is null")
            : new OperationStatus(validationProblemDetails.Errors);
    }


    var response = await _httpClient.PutAsync("api/item", itemJson);
     if (response.StatusCode == HttpStatusCode.NotFound)
     if (!response.IsSuccessStatusCode)
     {
     {
         var validationProblem = await JsonSerializer.DeserializeAsync<ValidationProblemDetails>(
         return new OperationStatus($"Item {id} has not been found.");
            await response.Content.ReadAsStreamAsync());
        // validationProblem.Errors: key = property, values = error list
     }
     }
}
 
    response.EnsureSuccessStatusCode();
 
    return new OperationStatus();
</kode>
</kode>


== DELETE ==
== DELETE ==
<kode lang='cs'>
<kode lang='cs'>
public async Task DeleteItemAsync(int itemId)
public async Task<OperationStatus> DeleteAsync(int id, CancellationToken cancellationToken)
{
    var uri = new Uri($"{Path}/{id}", UriKind.Relative);
    var response = await httpClient.DeleteAsync(uri, cancellationToken);
 
    if (response.StatusCode == HttpStatusCode.NotFound)
    {
        return new OperationStatus($"Item {id} has not been found.");
    }
 
    if (response.StatusCode == HttpStatusCode.InternalServerError)
    {
        var problemDetails = await response.Content.ReadAsAsync<ProblemDetails>(
            problemJsonMediaTypeFormatters, cancellationToken);
 
        var errorMessage = problemDetails is null || string.IsNullOrEmpty(problemDetails.Detail)
            ? "Unable to read the ProblemDetails error message"
            : problemDetails.Detail.Replace(" See the inner exception for details.", string.Empty);
 
        return new OperationStatus(errorMessage);
    }
 
    response.EnsureSuccessStatusCode();
 
    return new OperationStatus();
</kode>
 
= ClientBase =
ClientBase for
* GetById
* GetAll
* Create
* Update
* Delete
 
<filebox fn='ItemClient.cs'>
public class ItemClient :
    ClientBaseWithGetAll<ItemResponse, CreateUpdateItemQuery>, IItemClient
{
    protected override string Path => "item";
 
    public ItemClient(HttpClient httpClient)
        : base(httpClient)
    { }
}
</filebox>
 
<filebox fn='IItemClient.cs'>
public interface IItemClient :
    IClientBaseWithGetAll<ItemResponse, CreateUpdateItemQuery>
{ }
</filebox>
 
<filebox fn='ClientBase.cs' collapsed>
public abstract class ClientBase<TResponse, TCreateUpdateQuery> :
    IClientBase<TResponse, TCreateUpdateQuery>
{
    protected static IEnumerable<MediaTypeFormatter> problemJsonMediaTypeFormatters
        => new MediaTypeFormatter[1] { new ProblemJsonMediaTypeFormatter() };
 
    protected abstract string Path { get; }
    protected HttpClient HttpClient { get; }
 
    protected ClientBase(HttpClient httpClient)
    {
        HttpClient = httpClient;
    }
 
    public async Task<OperationResult<TResponse>> GetByIdAsync(
        int id,
        CancellationToken cancellationToken)
    {
        var uri = new Uri($"{Path}/{id}", UriKind.Relative);
 
        var response = await HttpClient.GetAsync(uri, cancellationToken);
        if (response.StatusCode == HttpStatusCode.NotFound)
        {
            return new OperationResult<TResponse>($"{typeof(TResponse)} {id} has not been found.");
        }
 
        response.EnsureSuccessStatusCode();
 
        var content = await response.Content.ReadAsAsync<TResponse>(cancellationToken);
 
        return new OperationResult<TResponse>(content);
    }
 
    public async Task<OperationStatus> DeleteAsync(int id, CancellationToken cancellationToken)
    {
        var uri = new Uri($"{Path}/{id}", UriKind.Relative);
 
        var response = await HttpClient.DeleteAsync(uri, cancellationToken);
 
        if (response.StatusCode == HttpStatusCode.NotFound)
        {
            return new OperationStatus($"{typeof(TResponse)} {id} has not been found.");
        }
 
        if (response.StatusCode == HttpStatusCode.InternalServerError)
        {
            var problemDetails = await response.Content.ReadAsAsync<ProblemDetails>(
                problemJsonMediaTypeFormatters, cancellationToken);
 
            var errorMessage = problemDetails is null || string.IsNullOrEmpty(problemDetails.Detail)
                ? "Unable to read the ProblemDetails error message"
                : problemDetails.Detail.Replace(" See the inner exception for details.", string.Empty);
 
            return new OperationStatus(errorMessage);
        }
 
        response.EnsureSuccessStatusCode();
 
        return new OperationStatus();
    }
 
    public async Task<OperationStatus> CreateAsync(
        TCreateUpdateQuery query,
        CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(query);
 
        var uri = new Uri(Path, UriKind.Relative);
        var queryJson = new StringContent(JsonSerializer.Serialize(query), Encoding.UTF8, "application/json");
 
        var response = await HttpClient.PostAsync(uri, queryJson, cancellationToken);
        if (response.StatusCode == HttpStatusCode.BadRequest)
        {
            var validationProblemDetails =
                await response.Content.ReadAsAsync<ValidationProblemDetails>(
                    problemJsonMediaTypeFormatters, cancellationToken);
 
            return validationProblemDetails is null
                ? throw new InvalidDataException($"{nameof(validationProblemDetails)} is null")
                : new OperationStatus(validationProblemDetails.Errors);
        }
 
        response.EnsureSuccessStatusCode();
 
        return new OperationStatus();
    }
 
    public async Task<OperationStatus> UpdateAsync(
        int id,
        TCreateUpdateQuery query,
        CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(query);
 
        var uri = new Uri($"{Path}/{id}", UriKind.Relative);
        var queryJson = new StringContent(JsonSerializer.Serialize(query), Encoding.UTF8, "application/json");
 
        var response = await HttpClient.PutAsync(uri, queryJson, cancellationToken);
        if (response.StatusCode == HttpStatusCode.BadRequest)
        {
            var validationProblemDetails =
                await response.Content.ReadAsAsync<ValidationProblemDetails>(
                    problemJsonMediaTypeFormatters, cancellationToken);
 
            return validationProblemDetails is null
                ? throw new InvalidDataException($"{nameof(validationProblemDetails)} is null")
                : new OperationStatus(validationProblemDetails.Errors);
        }
 
        if (response.StatusCode == HttpStatusCode.NotFound)
        {
            return new OperationStatus($"{typeof(TResponse)} {id} has not been found.");
        }
 
        response.EnsureSuccessStatusCode();
 
        return new OperationStatus();
    }
}
</filebox>
 
<filebox fn='IClientBase.cs' collapsed>
public interface IClientBase<TResponse, TCreateUpdateQuery>
{
    Task<OperationResult<TResponse>> GetByIdAsync(int id, CancellationToken cancellationToken);
    Task<OperationStatus> DeleteAsync(int id, CancellationToken cancellationToken);
 
    Task<OperationStatus> CreateAsync(
        TCreateUpdateQuery query,
        CancellationToken cancellationToken);
 
    Task<OperationStatus> UpdateAsync(
        int id,
        TCreateUpdateQuery query,
        CancellationToken cancellationToken);
}
</filebox>
 
<filebox fn='ClientBaseWithGetAll.cs' collapsed>
public abstract class ClientBaseWithGetAll<TResponse, TCreateUpdateQuery> :
    ClientBase<TResponse, TCreateUpdateQuery>
{
{
     var response = await _httpClient.DeleteAsync($"api/item/{itemId}");
     protected ClientBaseWithGetAll(HttpClient httpClient)
     if (!response.IsSuccessStatusCode)
        : base(httpClient)
    { }
 
     public async Task<IReadOnlyCollection<TResponse>> GetAllAsync(
        CancellationToken cancellationToken)
     {
     {
         var validationProblem = await JsonSerializer.DeserializeAsync<ValidationProblemDetails>(
         var uri = new Uri(Path, UriKind.Relative);
            await response.Content.ReadAsStreamAsync());
 
         throw new HttpRequestException($"{validationProblem.Title}: {validationProblem.Detail}");
        var response = await HttpClient.GetAsync(uri, cancellationToken);
        response.EnsureSuccessStatusCode();
 
        var content = await response.Content
            .ReadAsAsync<IReadOnlyCollection<TResponse>>(cancellationToken);
 
         return content;
     }
     }
}
}
</kode>
</filebox>
 
<filebox fn='IClientBaseWithGetAll.cs' collapsed>
public interface IClientBaseWithGetAll<TResponse, TCreateUpdateQuery> :
    IClientBase<TResponse, TCreateUpdateQuery>
{
    Task<IReadOnlyCollection<TResponse>> GetAllAsync(CancellationToken cancellationToken);
}
</filebox>


= Query Strings =
= Query Strings =
Ligne 593 : Ligne 941 :


return result;
return result;
</kode>
= JSON name mapping =
<kode lang='cs'>
[JsonPropertyName("otherName")]
public string Property1 { get; set; }
</kode>
</kode>


Ligne 613 : Ligne 967 :
* [https://stackoverflow.com/questions/9584573/model-binding-comma-separated-query-string-parameter CommaSeparatedModelBinder]
* [https://stackoverflow.com/questions/9584573/model-binding-comma-separated-query-string-parameter CommaSeparatedModelBinder]


<filebox fn='Startup.cs'>
<filebox fn='Program.cs'>
public void ConfigureServices(IServiceCollection services)
builder.Services.AddControllers(options =>
{
{
     services.AddControllers(options =>
     options.ModelBinderProviders.Insert(0, new CommaSeparatedModelBinderProvider());
    {
});
        options.ModelBinderProviders.Insert(0, new CommaSeparatedModelBinderProvider());
    });
</filebox>
</filebox>


Ligne 625 : Ligne 977 :
public class CommaSeparatedModelBinderProvider : IModelBinderProvider
public class CommaSeparatedModelBinderProvider : IModelBinderProvider
{
{
     public IModelBinder GetBinder(ModelBinderProviderContext context)
     public IModelBinder? GetBinder(ModelBinderProviderContext context)
     {
     {
         if (context == null)
         ArgumentNullException.ThrowIfNull(context);
        {
            throw new ArgumentNullException(nameof(context));
        }


         if (context.Metadata.ModelType.GetInterface(nameof(IEnumerable)) != null
         if (context.Metadata.ModelType.GetInterface(nameof(IEnumerable)) != null
Ligne 646 : Ligne 995 :
public class CommaSeparatedModelBinder : IModelBinder
public class CommaSeparatedModelBinder : IModelBinder
{
{
     private static readonly MethodInfo ToArrayMethod = typeof(Enumerable).GetMethod("ToArray");
     private static readonly MethodInfo ToArrayMethod = typeof(Enumerable).GetMethod("ToArray")!;


     public Task BindModelAsync(ModelBindingContext bindingContext)
     public Task BindModelAsync(ModelBindingContext bindingContext)
     {
     {
         if (bindingContext == null)
         ArgumentNullException.ThrowIfNull(bindingContext);
        {
            throw new ArgumentNullException(nameof(bindingContext));
        }


         var modelType = bindingContext.ModelType;
         var modelType = bindingContext.ModelType;
Ligne 681 : Ligne 1 027 :
             }
             }


             var list = (IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(valueType));
             var list = (IList?)Activator.CreateInstance(typeof(List<>).MakeGenericType(valueType));
             foreach (var splitValue in value.Split(new[] { ',' }))
             if (list is not null)
             {
             {
                 if (!string.IsNullOrWhiteSpace(splitValue))
                 foreach (var splitValue in value.Split(commaSeparator))
                 {
                 {
                     list.Add(Convert.ChangeType(splitValue, valueType));
                     if (!string.IsNullOrWhiteSpace(splitValue))
                    {
                        list.Add(Convert.ChangeType(splitValue, valueType));
                    }
                 }
                 }
             }
             }
Ligne 699 : Ligne 1 048 :
         return Task.CompletedTask;
         return Task.CompletedTask;
     }
     }
}
</filebox>
= [https://brokul.dev/sending-files-and-additional-data-using-httpclient-in-net-core Handle files] =
<filebox fn='FileClient.cs'>
await using var stream = System.IO.File.OpenRead("/path/MyFile.txt");
using var content = new MultipartFormDataContent
{
    { new StreamContent(stream), "file", "MyFile.txt" }
};
var response = await HttpClient.PostAsync(uri, content, cancellationToken);
</filebox>
<filebox fn='FileController.cs'>
[HttpPost]
public async Task<IActionResult> ImportAsync(
    [FromForm] IFormFile file,
    CancellationToken cancellationToken)
{
    using var memoryStream = new MemoryStream();
    await file.CopyToAsync(memoryStream, cancellationToken);
    byte[] b = memoryStream.ToArray();
}
</filebox>
= Kebab case =
<filebox fn='Program.cs'>
builder.Services
    .AddControllers(x =>
        x.Conventions.Add(new RouteTokenTransformerConvention(new KebabCaseParameterTransformer())));
</filebox>
<filebox fn='KebabCaseParameterTransformer.cs'>
public partial class KebabCaseParameterTransformer : IOutboundParameterTransformer
{
    [GeneratedRegex("([a-z])([A-Z])")]
    private static partial Regex kebabCaseRegex();
    public string TransformOutbound(object value)
        => value == null ? null : kebabCaseRegex().Replace(value.ToString(), "$1-$2").ToLower();
}
}
</filebox>
</filebox>

Dernière version du 10 juin 2024 à 12:48

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
[ApiVersion("1")]               // need nuget package Microsoft.AspNetCore.Mvc.Versioning
[ApiController]
[Route("[controller]")]         // /item
[Produces("application/json")]  // inform about the output format, default is plain text (force le format JSON?)
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(ItemResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetByIdAsync(int id, CancellationToken cancellationToken)
{
    var item = await context.Items
        .AsNoTracking()
        .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);

    return item is null ? NotFound() : Ok(mapper.Map<ItemResponse>(item));
}

[HttpGet]
[ProducesResponseType(typeof(IReadOnlyCollection<ItemResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
public async Task<IActionResult> GetAsync([FromQuery] ItemQuery query, CancellationToken cancellationToken)
{
    var predicate = PredicateBuilder.New<Item>(true);
    if (!string.IsNullOrEmpty(query.Name))
    {
        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<ItemResponse>>(items));
}

POST

Csharp.svg
[HttpPost]
[ProducesResponseType(typeof(ItemResponse), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
public async Task<IActionResult> CreateAsync(CreateUpdateItemQuery query, CancellationToken cancellationToken)
{
    var item = this.mapper.Map<Item>(query);
    await context.AddAsync(item, cancellationToken);
    await context.SaveChangesAsync(cancellationToken);

    var response = this.mapper.Map<ItemResponse>(item);

    // uri to the newly created item
    var uri = new Uri($"https://www.site.net/items/{createdItem.Id}");
    return Created(uri, response);

    var actionName = nameof(ItemController.GetByIdAsync);  // default to controller method name. Tmo handle Async suffix see the warning box below
    var controllerName = "Item";  // default to controller name. Don't add the 'Controller' suffix
    var routeValues = new { id = item.Id }; // path and query parameters
    return CreatedAtAction(actionName, controllerName, routeValues, response);

    var routeValues = new
    {
        controller = "Item",  // name of the controller without "Controller", set only if it is a different one
        action = nameof(ItemController.GetByIdAsync),  // name of a controller method, optional
        id = item.Id  // set the id and all other needed parameters
    };
    return CreatedAtRoute(routeValues, createdResource);
}

To handle Async suffix in CreatedAtAction

Program.cs
builder.Services.AddControllers(options =>
{
    options.SuppressAsyncSuffixInActionNames = false;
});

PUT / Update

Csharp.svg
[HttpPut("{id}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> UpdateAsync(int id, CreateUpdateItemQuery query)
{
    var itemToUpdate = await context.Items.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
    if (itemToUpdate is null)
    {
        return NotFound();
    }

    this.mapper.Map(query, itemToUpdate);
    await context.SaveChangesAsync(cancellationToken);

    return NoContent();
}

DELETE

Cs.svg
[HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteAsync(int id, CancellationToken cancellationToken)
{
    if (!await context.Items.AnyAsync(x => x.Id == id, cancellationToken))
    {
        return NotFound();
    }

    context.Remove(new Item { Id = id });
    await context.SaveChangesAsync(cancellationToken);
    return NoContent();
}

Return exceptions as problem details response

By default exceptions are returned in plain text.
With the problem details service it returns now a problem details response.
In case of exception the media type returned is application/problem+json which is not handled by default on the client side.
Program.cs
//builder.Services.AddControllers();
builder.Services.AddProblemDetails();

//var app = builder.Build();

app.UseExceptionHandler();
app.UseStatusCodePages();


if (app.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
}

//app.MapControllers();
//app.Run();

OLD 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: 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

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

CORS

Programs.cs
// app.UseStaticFiles();

// fix access from origin '...' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource
// allow all origins
app.UseCors(x => x.SetIsOriginAllowed(origin => true));
// allow https://localhost:4200
app.UseCors(x => x.WithOrigins("https://localhost:4200"));

Client

Client/ItemClient.cs
public class ItemClient : IItemClient
{
    private const string PATH = "item";
    private const string APIVERSION = "1";

    protected static IEnumerable<MediaTypeFormatter> problemJsonMediaTypeFormatters
        => new MediaTypeFormatter[1] { new ProblemJsonMediaTypeFormatter() };

    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);
    }
Clients/OperationResult.cs
public class OperationResult<T>
{
    private readonly IDictionary<string, string[]> validationErrors;

    public T? Result { get; }
    public IReadOnlyCollection<string> ErrorMessages => validationErrors.SelectMany(x => x.Value).ToList();
    public bool IsValid => validationErrors.Count == 0;

    // used to handle errors of server validation
    public OperationResult(IDictionary<string, string[]> validationErrors)
    {
        this.validationErrors = validationErrors;
    }

    // used to handle errros of client validation with FluentValidation
    public OperationResult(IEnumerable<ValidationFailure> validationFailures)
    {
        validationErrors = validationFailures
            .GroupBy(x => x.PropertyName, x => x.ErrorMessage)
            .ToDictionary(x => x.Key, x => x.ToArray());
    }

    // used to handle error
    public OperationResult(string errorMessage)
    {
        validationErrors = new Dictionary<string, string[]> { { string.Empty, new string[] { errorMessage } } };
    }

    // use to hanlde valid result
    public OperationResult(T result)
    {
        Result = result;
        validationErrors = new Dictionary<string, string[]>();
    }
}
Clients/OperationStatus.cs
public class OperationStatus
{
    public string ErrorMessage { get; }
    public IDictionary<string, string[]> Errors { get; }
    public bool Success => string.IsNullOrEmpty(ErrorMessage) && Errors.Count == 0;

    // use to hanlde valid status
    public OperationStatus()
    {
        ErrorMessage = string.Empty;
        Errors = new Dictionary<string, string[]>();
    }

    // used to handle single error of server validation
    public OperationStatus(string errorMessage)
    {
        ErrorMessage = errorMessage;
        Errors = new Dictionary<string, string[]>();
    }

    // used to handle errors of server validation
    public OperationStatus(IDictionary<string, string[]> errors)
    {
        ErrorMessage = string.Empty;
        Errors = errors;
    }
}
MediaTypeFormatters/ProblemJsonMediaTypeFormatter.cs
public class ProblemJsonMediaTypeFormatter : JsonMediaTypeFormatter
{
    private static readonly MediaTypeHeaderValue problemJsonMediaType = new("application/problem+json");

    public ProblemJsonMediaTypeFormatter()
    {
        SupportedMediaTypes.Add(problemJsonMediaType);
    }
}
Extensions/QueryExtensions.cs
public static class QueryExtensions
{
    public static QueryString ToQueryString(this FetchItemQuery query)
    {
        var queryBuilder = new QueryBuilder
        {
            { nameof(query.PageIndex), query.PageIndex.ToString(CultureInfo.InvariantCulture) },
            { nameof(query.PageSize), query.PageSize.ToString(CultureInfo.InvariantCulture) }
        };

        queryBuilder.AddIfValueNotNullNorEmpty(nameof(query.MyProperty), query.MyProperty);

        return queryBuilder.ToQueryString();
    }
}
Extensions/QueryBuilderExtensions.cs
public static class QueryBuilderExtensions
{
    public static void AddIfValueNotNullNorEmpty(this QueryBuilder source, string key, string value)
    {
        if (string.IsNullOrEmpty(value))
        {
            return;
        }

        source.Add(key, value);
    }
}

Dependency Injection

Program.cs
services
    .AddHttpClient<IMyClient, MyClient>(x =>
    {
        var authenticationString = $"{username}:{password}";
        var base64EncodedAuthenticationString = Convert.ToBase64String(Encoding.ASCII.GetBytes(authenticationString));

        x.BaseAddress = new Uri("https://www.address.net");
        x.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", base64EncodedAuthenticationString);
        x.DefaultRequestHeaders.Add("User-Agent", "My App");
    })
    .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
    {
        Proxy = new WebProxy
        {
            Address = new Uri($"http://localhost:9000"),
            BypassProxyOnLocal = false,
            UseDefaultCredentials = false,
        },
    });

GET

ReadAsAsync

Cs.svg
public async Task<OperationResult<IReadOnlyCollection<ItemResponse>>> GetAsync(FetchItemQuery query, CancellationToken cancellationToken)
{
    ArgumentNullException.ThrowIfNull(query);

    var uri = new Uri($"{PATH}{query.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);
}

public async Task<OperationResult<TResponse>> GetByIdAsync(int id, CancellationToken cancellationToken)
{
    var uri = new Uri($"{PATH}/{id}", UriKind.Relative);

    var response = await httpClient.GetAsync(uri, cancellationToken);
    if (response.StatusCode == HttpStatusCode.NotFound)
    {
        return new OperationResult<TResponse>($"Item {id} has not been found.");
    }

    response.EnsureSuccessStatusCode();

    var item = await response.Content.ReadAsAsync<ItemResponse>(cancellationToken);

    return new OperationResult<ItemResponse>(item);
}
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<OperationStatus> CreateAsync(CreateUpdateItemQuery query)
{
    ArgumentNullException.ThrowIfNull(query);

    var uri = new Uri(PATH, UriKind.Relative);
    var queryJson = new StringContent(JsonSerializer.Serialize(query), Encoding.UTF8, "application/json");

    var response = await httpClient.PostAsync(uri, queryJson);
    if (response.StatusCode == HttpStatusCode.BadRequest)
    {
        var validationProblemDetails =
            await response.Content.ReadAsAsync<ValidationProblemDetails>(
                problemJsonMediaTypeFormatters, cancellationToken);

        return validationProblemDetails is null
            ? throw new InvalidDataException($"{nameof(validationProblemDetails)} is null")
            : new OperationStatus(validationProblemDetails.Errors);
    }

    response.EnsureSuccessStatusCode();

    // var item = await response.Content.ReadAsAsync<ItemResponse>(cancellationToken);

    return new OperationStatus();

PUT

Cs.svg
public async Task<OperationStatus> UpdateAsync(int id, CreateUpdateItemQuery query, CancellationToken cancellationToken)
{
    ArgumentNullException.ThrowIfNull(query);

    var uri = new Uri($"{Path}/{id}", UriKind.Relative);
    var queryJson = new StringContent(JsonSerializer.Serialize(query), Encoding.UTF8, "application/json");

    var response = await httpClient.PutAsync(uri, queryJson, cancellationToken);
    if (response.StatusCode == HttpStatusCode.BadRequest)
    {
        var validationProblemDetails =
            await response.Content.ReadAsAsync<ValidationProblemDetails>(
                problemJsonMediaTypeFormatters, cancellationToken);

        return validationProblemDetails is null
            ? throw new InvalidDataException($"{nameof(validationProblemDetails)} is null")
            : new OperationStatus(validationProblemDetails.Errors);
    }

    if (response.StatusCode == HttpStatusCode.NotFound)
    {
        return new OperationStatus($"Item {id} has not been found.");
    }

    response.EnsureSuccessStatusCode();

    return new OperationStatus();

DELETE

Cs.svg
public async Task<OperationStatus> DeleteAsync(int id, CancellationToken cancellationToken)
{
    var uri = new Uri($"{Path}/{id}", UriKind.Relative);
    var response = await httpClient.DeleteAsync(uri, cancellationToken);

    if (response.StatusCode == HttpStatusCode.NotFound)
    {
        return new OperationStatus($"Item {id} has not been found.");
    }

    if (response.StatusCode == HttpStatusCode.InternalServerError)
    {
        var problemDetails = await response.Content.ReadAsAsync<ProblemDetails>(
            problemJsonMediaTypeFormatters, cancellationToken);

        var errorMessage = problemDetails is null || string.IsNullOrEmpty(problemDetails.Detail)
            ? "Unable to read the ProblemDetails error message"
            : problemDetails.Detail.Replace(" See the inner exception for details.", string.Empty);

        return new OperationStatus(errorMessage);
    }

    response.EnsureSuccessStatusCode();

    return new OperationStatus();

ClientBase

ClientBase for

  • GetById
  • GetAll
  • Create
  • Update
  • Delete
ItemClient.cs
public class ItemClient :
    ClientBaseWithGetAll<ItemResponse, CreateUpdateItemQuery>, IItemClient
{
    protected override string Path => "item";

    public ItemClient(HttpClient httpClient)
        : base(httpClient)
    { }
}
IItemClient.cs
public interface IItemClient :
    IClientBaseWithGetAll<ItemResponse, CreateUpdateItemQuery>
{ }
ClientBase.cs
public abstract class ClientBase<TResponse, TCreateUpdateQuery> :
    IClientBase<TResponse, TCreateUpdateQuery>
{
    protected static IEnumerable<MediaTypeFormatter> problemJsonMediaTypeFormatters
        => new MediaTypeFormatter[1] { new ProblemJsonMediaTypeFormatter() };

    protected abstract string Path { get; }
    protected HttpClient HttpClient { get; }

    protected ClientBase(HttpClient httpClient)
    {
        HttpClient = httpClient;
    }

    public async Task<OperationResult<TResponse>> GetByIdAsync(
        int id,
        CancellationToken cancellationToken)
    {
        var uri = new Uri($"{Path}/{id}", UriKind.Relative);

        var response = await HttpClient.GetAsync(uri, cancellationToken);
        if (response.StatusCode == HttpStatusCode.NotFound)
        {
            return new OperationResult<TResponse>($"{typeof(TResponse)} {id} has not been found.");
        }

        response.EnsureSuccessStatusCode();

        var content = await response.Content.ReadAsAsync<TResponse>(cancellationToken);

        return new OperationResult<TResponse>(content);
    }

    public async Task<OperationStatus> DeleteAsync(int id, CancellationToken cancellationToken)
    {
        var uri = new Uri($"{Path}/{id}", UriKind.Relative);

        var response = await HttpClient.DeleteAsync(uri, cancellationToken);

        if (response.StatusCode == HttpStatusCode.NotFound)
        {
            return new OperationStatus($"{typeof(TResponse)} {id} has not been found.");
        }

        if (response.StatusCode == HttpStatusCode.InternalServerError)
        {
            var problemDetails = await response.Content.ReadAsAsync<ProblemDetails>(
                problemJsonMediaTypeFormatters, cancellationToken);

            var errorMessage = problemDetails is null || string.IsNullOrEmpty(problemDetails.Detail)
                ? "Unable to read the ProblemDetails error message"
                : problemDetails.Detail.Replace(" See the inner exception for details.", string.Empty);

            return new OperationStatus(errorMessage);
        }

        response.EnsureSuccessStatusCode();

        return new OperationStatus();
    }

    public async Task<OperationStatus> CreateAsync(
        TCreateUpdateQuery query,
        CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(query);

        var uri = new Uri(Path, UriKind.Relative);
        var queryJson = new StringContent(JsonSerializer.Serialize(query), Encoding.UTF8, "application/json");

        var response = await HttpClient.PostAsync(uri, queryJson, cancellationToken);
        if (response.StatusCode == HttpStatusCode.BadRequest)
        {
            var validationProblemDetails =
                await response.Content.ReadAsAsync<ValidationProblemDetails>(
                    problemJsonMediaTypeFormatters, cancellationToken);

            return validationProblemDetails is null
                ? throw new InvalidDataException($"{nameof(validationProblemDetails)} is null")
                : new OperationStatus(validationProblemDetails.Errors);
        }

        response.EnsureSuccessStatusCode();

        return new OperationStatus();
    }

    public async Task<OperationStatus> UpdateAsync(
        int id,
        TCreateUpdateQuery query,
        CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(query);

        var uri = new Uri($"{Path}/{id}", UriKind.Relative);
        var queryJson = new StringContent(JsonSerializer.Serialize(query), Encoding.UTF8, "application/json");

        var response = await HttpClient.PutAsync(uri, queryJson, cancellationToken);
        if (response.StatusCode == HttpStatusCode.BadRequest)
        {
            var validationProblemDetails =
                await response.Content.ReadAsAsync<ValidationProblemDetails>(
                    problemJsonMediaTypeFormatters, cancellationToken);

            return validationProblemDetails is null
                ? throw new InvalidDataException($"{nameof(validationProblemDetails)} is null")
                : new OperationStatus(validationProblemDetails.Errors);
        }

        if (response.StatusCode == HttpStatusCode.NotFound)
        {
            return new OperationStatus($"{typeof(TResponse)} {id} has not been found.");
        }

        response.EnsureSuccessStatusCode();

        return new OperationStatus();
    }
}
IClientBase.cs
public interface IClientBase<TResponse, TCreateUpdateQuery>
{
    Task<OperationResult<TResponse>> GetByIdAsync(int id, CancellationToken cancellationToken);
    Task<OperationStatus> DeleteAsync(int id, CancellationToken cancellationToken);

    Task<OperationStatus> CreateAsync(
        TCreateUpdateQuery query,
        CancellationToken cancellationToken);

    Task<OperationStatus> UpdateAsync(
        int id,
        TCreateUpdateQuery query,
        CancellationToken cancellationToken);
}
ClientBaseWithGetAll.cs
public abstract class ClientBaseWithGetAll<TResponse, TCreateUpdateQuery> :
    ClientBase<TResponse, TCreateUpdateQuery>
{
    protected ClientBaseWithGetAll(HttpClient httpClient)
        : base(httpClient)
    { }

    public async Task<IReadOnlyCollection<TResponse>> GetAllAsync(
        CancellationToken cancellationToken)
    {
        var uri = new Uri(Path, UriKind.Relative);

        var response = await HttpClient.GetAsync(uri, cancellationToken);
        response.EnsureSuccessStatusCode();

        var content = await response.Content
            .ReadAsAsync<IReadOnlyCollection<TResponse>>(cancellationToken);

        return content;
    }
}
IClientBaseWithGetAll.cs
public interface IClientBaseWithGetAll<TResponse, TCreateUpdateQuery> :
    IClientBase<TResponse, TCreateUpdateQuery>
{
    Task<IReadOnlyCollection<TResponse>> GetAllAsync(CancellationToken cancellationToken);
}

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 name mapping

Cs.svg
[JsonPropertyName("otherName")]
public string Property1 { get; set; }

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

Program.cs
builder.Services.AddControllers(options =>
{
    options.ModelBinderProviders.Insert(0, new CommaSeparatedModelBinderProvider());
});
CommaSeparatedModelBinderProvider.cs
public class CommaSeparatedModelBinderProvider : IModelBinderProvider
{
    public IModelBinder? GetBinder(ModelBinderProviderContext context)
    {
        ArgumentNullException.ThrowIfNull(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)
    {
        ArgumentNullException.ThrowIfNull(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));
            if (list is not null)
            {
                foreach (var splitValue in value.Split(commaSeparator))
                {
                    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;
    }
}

Handle files

FileClient.cs
await using var stream = System.IO.File.OpenRead("/path/MyFile.txt");
using var content = new MultipartFormDataContent
{
    { new StreamContent(stream), "file", "MyFile.txt" }
};

var response = await HttpClient.PostAsync(uri, content, cancellationToken);
FileController.cs
[HttpPost]
public async Task<IActionResult> ImportAsync(
    [FromForm] IFormFile file,
    CancellationToken cancellationToken)
{
    using var memoryStream = new MemoryStream();
    await file.CopyToAsync(memoryStream, cancellationToken);

    byte[] b = memoryStream.ToArray();
}

Kebab case

Program.cs
builder.Services
    .AddControllers(x =>
        x.Conventions.Add(new RouteTokenTransformerConvention(new KebabCaseParameterTransformer())));
KebabCaseParameterTransformer.cs
public partial class KebabCaseParameterTransformer : IOutboundParameterTransformer
{
    [GeneratedRegex("([a-z])([A-Z])")]
    private static partial Regex kebabCaseRegex();

    public string TransformOutbound(object value)
        => value == null ? null : kebabCaseRegex().Replace(value.ToString(), "$1-$2").ToLower();
}

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