Asp.net core 8 web api

De Banane Atomic
Aller à la navigationAller à la recherche

Links

New projet

Bash.svg
dotnet new webapi -o [project-name] --no-https
On VS to not open the web brower when you start the application: right-click on the project → Properties → Debug → uncheck Launch browser

Controller

Controllers/ItemsController.cs
[ApiController]
[Route("[controller]")]         // /item
[Produces("application/json")]  // inform about the output format, default is plain text (force le format JSON?)
public class ItemController(IItemService itemService) : ControllerBase
{ /* ... */ }

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

Headers

Cs.svg
Response.Headers.Append("Key", "Value");

Version

Program.cs
// nuget package Asp.Versioning.Mvc
builder.Services
    .AddApiVersioning(options =>
    {
        options.ReportApiVersions = true;
        options.AssumeDefaultVersionWhenUnspecified = true;
        options.DefaultApiVersion = new ApiVersion(1, 0);
    });
ItemController.cs
[ApiVersion("1.0")]
[Route("v{version:apiVersion}/[controller]")]
[ApiController]
public class ItemController : ControllerBase
{ }

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

URL with kebab case

Program.cs
builder.Services
    .AddControllers(options =>
    {
        options.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();
}

Client

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

    private readonly HttpClient httpClient;
    private readonly QueryBuilder queryBuilder;

    // injection de HttpClient
    public ItemClient(HttpClient httpClient)
    {
        this.httpClient = httpClient;

        queryBuilder = new QueryBuilder();
        queryBuilder.Add("api-version", APIVERSION);
    }
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;
    }
}
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

Typed clients are transient objects usually injected into services.
They are expected to be short-lived.
If a typed client instance is captured in a singleton, it may prevent it from reacting to DNS changes, defeating one of the purposes of IHttpClientFactory.

Program.cs
services
    .AddHttpClient<IMyClient, MyClient>(x =>
    {
        x.DefaultRequestHeaders.Authorization = // AuthenticationHeaderValue see below

        x.BaseAddress = new Uri("https://www.address.net");
        x.DefaultRequestHeaders.UserAgent.ParseAdd("my-app");
        x.Timeout = TimeSpan.FromSeconds(100); // default 100s
    })
    .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
    {
        Proxy = new WebProxy
        {
            Address = new Uri($"http://localhost:9000"),
            BypassProxyOnLocal = false,
            UseDefaultCredentials = false,
        },
    });

Basic Base64 user password authentication

Cs.svg
var authenticationString = $"{username}:{password}";
var base64EncodedAuthenticationString = Convert.ToBase64String(Encoding.ASCII.GetBytes(authenticationString));
x.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", base64EncodedAuthenticationString);

AAD OAuth2 JWT authentication

Cs.svg
var builder = ConfidentialClientApplicationBuilder
    .Create(clientId)
    .WithClientSecret(clientSecret)
    .WithAuthority(new Uri(authorityUri))
    .Build();
var result = await builder
    .AcquireTokenForClient([scope])
    .ExecuteAsync();
x.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", result.AccessToken);

Cognito OAuth2 JWT authentication

Cs.svg
var request = new HttpRequestMessage(HttpMethod.Post, tokenEndPoint);
var authHeader = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{clientId}:{clientSecret}"));
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", authHeader);
request.Content = new FormUrlEncodedContent(
[
    new KeyValuePair<string, string>("grant_type", "client_credentials"),
    new KeyValuePair<string, string>("scope", scope)
]);

using var httpClient = new HttpClient();
var response = await httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();

var responseContent = await response.Content.ReadAsStringAsync();
var tokenResponse = JObject.Parse(responseContent);
var accessToken = tokenResponse["access_token"].ToString();

x.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);

Cache

Program.cs
services.AddMemoryCache();  // inject IMemoryCache
MyClient.cs
public class MyClient(HttpClient httpClient, IMemoryCache cache) : IMyClient
{
    private static readonly TimeSpan expiration = TimeSpan.FromHours(1);

    private async Task<Item[]> GetItemsAsync()
    {
        var uri = "items";

        if (cache.TryGetValue(uri, out Item[]? cachedResponse))
            return cachedResponse;

        var response = await httpClient.GetAsync(uri);
        if (!response.IsSuccessStatusCode)
        {
            // handle error
            var errorMessage = $"url: {httpClient.BaseAddress}/{uri}, status code: {response.StatusCode}, content: {await response.Content.ReadAsStringAsync()}";
            return [];
        }

        var content = await response.Content.ReadFromJsonAsync<Item[]?>();
        cache.Set(uri, content, expiration);

        return content;
    }

GET

Cs.svg
// all in 1: get response, EnsureSuccessStatusCode, then deserialize the content
var items = await httpClient.GetFromJsonAsync<ItemResponse[]>(uri) ?? [];

// step by step
var response = await HttpClient.GetAsync(uri, cancellationToken);
if (response.StatusCode == HttpStatusCode.???)
    // handle status code error

response.EnsureSuccessStatusCode();

var items = (await response.Content.ReadFromJsonAsync<ItemResponse[]>(cancellationToken)) ?? [];

Json Converters

Bash.svg
public class ItemResponse
{
    [JsonConverter(typeof(JsonStringEnumConverter))]  // allow (de)serialization from/to string
    public ItemType ItemType { get; set; }
    [JsonConverter(typeof(JsonStringBoolConverter))]  // allow (de)serialization from/to string
    public bool Archived { get; set; }
}

public enum ItemType
{
    Main,
    Specific
}
JsonStringBoolConverter.cs
public class JsonStringBoolConverter: JsonConverter<bool>
{
    public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options)
        => writer.WriteBooleanValue(value);

    public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        => reader.TokenType switch
        {
            JsonTokenType.True => true,
            JsonTokenType.False => false,
            JsonTokenType.String
                => bool.TryParse(reader.GetString(), out var b) ? b : throw new JsonException(),
            JsonTokenType.Number
                => reader.TryGetInt64(out var l)
                ? Convert.ToBoolean(l)
                : reader.TryGetDouble(out var d) && Convert.ToBoolean(d),
            _ => throw new JsonException(),
        };
}

POST

Cs.svg
public async Task<OperationStatus> CreateAsync(CreateUpdateItemQuery query)
{
    ArgumentNullException.ThrowIfNull(query);

    var uri = new Uri(PATH, UriKind.Relative);

    var response = await httpClient.PostAsJsonAsync(uri, query);
    // old way
    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.ReadFromJsonAsync<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}";
}

Action Filter

UpdateMyClassFilter.cs
public class UpdateMyClassFilter : IActionFilter
{
    public void OnActionExecuting(ActionExecutingContext context)
    {
        if (context.ActionArguments.Values.FirstOrDefault(x => x is MyClass) is MyClass myClass)
        {
            // do modification on myClass
        }
    }

    public void OnActionExecuted(ActionExecutedContext context) { }
}
EmptyResultTo204Filter.cs
public class EmptyResultTo204Filter : IActionFilter
{
    public void OnActionExecuting(ActionExecutingContext context)
    { }

    public void OnActionExecuted(ActionExecutedContext context)
    {
        if (context.Result is EmptyResult)
            context.Result = new StatusCodeResult(StatusCodes.Status204NoContent);
    }
}
Program.cs
builder.Services
    .AddControllers(x =>
    {
        x.Filters.Add<MyFilter>();
    });

Middleware

EnsureNoContentMiddleware.cs
/// <summary>
/// A middleware to change the Status Code to 204 when it is equal to 200 and there is no content. 
/// </summary>
public class EnsureNoContentMiddleware(RequestDelegate next)
{
    public async Task InvokeAsync(HttpContext context)
    {
        // Call the next middleware in the pipeline
        await next(context);

        // If the response has no content and no explicit status code is set, default to 204 No Content
        if (context.Response.StatusCode == StatusCodes.Status200OK
            && context.Response.ContentLength == null
            && !context.Response.HasStarted)
        {
            context.Response.StatusCode = StatusCodes.Status204NoContent;
        }
    }
}
Program.cs
app.UseMiddleware<EnsureNoContentMiddleware>();

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

Log

Add trace level for HttpClient, so you will get url and headers.

appsettings.json
{
  "Logging": {
    "LogLevel": {
      "System.Net.Http.HttpClient": "Trace"
    }
  }
}

Request timeouts middleware

To test in Visual Studio, run without the debugger.
TaskController.cs
[ApiController]
[Route("[controller]")]
public class TaskController : ControllerBase
{
    [HttpPost("run")]
    [RequestTimeout(1000)] // 1000 ms
    [RequestTimeout("OneSecondPolicy")]
    public async Task RunTaskAsync(CancellationToken cancellationToken)
    {
        await Task.Delay(2000, cancellationToken);
    }
}
Program.cs
builder.Services.AddRequestTimeouts();

builder.Services.AddRequestTimeouts(options => {
    options.DefaultPolicy = new RequestTimeoutPolicy { Timeout = TimeSpan.FromMilliseconds(1000) }; // default policy
    options.AddPolicy("OneSecondPolicy", TimeSpan.FromSeconds(1)); // named policy
});

app.UseRequestTimeouts();

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