Links
New projet
|
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 |
Controllers/ItemsController.cs
|
[ApiController]
[Produces("application/json")] // force le format JSON
[ApiVersion("1")] // need nuget package Microsoft.AspNetCore.Mvc.Versioning
[Route("[controller]")] // /item
public class ItemController : ControllerBase
{
private readonly IItemRepository itemRepository;
private readonly IMapper mapper;
public ItemsController(IItemRepository itemRepository, IMapper mapper)
{
this.itemRepository = itemRepository;
this.mapper = mapper;
|
GET
|
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(ItemDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetItemByIdAsync(int id, CancellationToken cancellationToken)
{
if (id <= 0)
{
return BadRequest("id must be greater than 0");
}
var item = await context.Items
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
return transaction is null ? NotFound(id) : Ok(mapper.Map<ItemDto>(item));
}
[HttpGet]
[ProducesResponseType(typeof(IReadOnlyCollection<ItemDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetAsync([FromQuery] ItemQuery query, CancellationToken cancellationToken)
{
var predicate = PredicateBuilder.New<Item>(true);
if (query.Name != default)
{
predicate = predicate.And(x => EF.Functions.Like(x.Name, $"%{query.Name}%"));
}
if (query.Ids != null && query.Ids.Count > 0)
{
predicate = predicate.And(x => query.Ids.Contains(x.Id));
}
var items = await context.Items
.Where(predicate)
.AsNoTracking()
.ToListAsync(cancellationToken);
return Ok(mapper.Map<IReadOnlyCollection<ItemDto>>(items));
}
// route: /[controller-route]/test
[HttpGet("test")]
public async Task<IActionResult> TestAsync() { }
|
POST
|
[HttpPost]
[ProducesResponseType(typeof(ItemDto), StatusCodes.Status201Created)]
public async Task<IActionResult> CreateItemAsync(CreateUpdateItemCommand command, CancellationToken cancellationToken)
{
var item = this.mapper.Map<Item>(command);
await context.AddAsync(item, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var response = this.mapper.Map<CreateItemResponse>(item);
return Created(HttpContext.Request.Path, response);
return Created($"https://localhost:5001/api/items/{createdItem.Id}", response);
return CreatedAtAction(nameof(Get), new { id = createdItem.Id }, response);
return CreatedAtRoute("api", new {
controller = "items",
action = nameof(Get),
id = $"{response.Id}"
}, response);
}
|
|
[HttpPut("{id}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> UpdateAsync(int id, CreateUpdateItemCommand command)
{
if (id <= 0)
{
return BadRequest("id must be greater than 0");
}
var itemToUpdate = await context.Transactions.FirstOrDefaultAsync(x => x.Id == id, cancellationToken)
if (itemToUpdate == null)
{
return NotFound();
}
this.mapper.Map(command, itemToUpdate);
await context.SaveChangesAsync(cancellationToken);
return NoContent();
}
|
DELETE
|
[HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteItemAsync(int id, CancellationToken cancellationToken)
{
if (id <= 0)
{
return BadRequest("id must be greater than 0.");
}
if (!await context.Transactions.AnyAsync(x => x.Id == id, cancellationToken))
{
return NotFound(id);
}
context.Remove(new Item { Id = id });
await context.SaveChangesAsync(cancellationToken);
return NoContent();
}
|
|
In case of exception the media type returned is application/problem+json which is not handled by default on the client side. |
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
|
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
|
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
|
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);
}
|
GET
ReadAsAsync
|
private static IEnumerable<MediaTypeFormatter> problemJsonMediaTypeFormatters
=> new MediaTypeFormatter[1] { new ProblemJsonMediaTypeFormatter() };
public async Task<OperationResult<IReadOnlyCollection<ItemResponse>>> GetItemsAsync(ItemQuery 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);
}
|
Clients/OperationResult.cs
|
public class OperationResult<T>
{
public T? Result { get; set; }
public IDictionary<string, string[]> ValidationErrors { get; } // error messages grouped by property name
public IReadOnlyCollection<string> Errors => ValidationErrors.SelectMany(x => x.Value).ToList();
public bool IsValid => ValidationErrors.Count == 0;
public OperationResult(IDictionary<string, string[]> validationErrors) // for server side validation error
{
ValidationErrors = validationErrors;
}
public OperationResult(IEnumerable<ValidationFailure> validationFailures) // for client side validation errors
{
ValidationErrors = validationFailures
.GroupBy(x => x.PropertyName, x => x.ErrorMessage)
.ToDictionary(x => x.Key, x => x.ToArray());
}
public OperationResult(T result) // for valid result
{
Result = result;
ValidationErrors = new Dictionary<string, string[]>();
}
}
|
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 ItemQuery 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);
}
}
|
|
dotnet add package Microsoft.AspNet.WebApi.Client
|
JsonSerializer.DeserializeAsync
|
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
|
public async Task<Item> AddItemAsync(Item item)
{
var itemJson =
new StringContent(JsonSerializer.Serialize(item), Encoding.UTF8, "application/json");
var response = await _httpClient.PostAsync("api/item", itemJson);
if (response.IsSuccessStatusCode)
{
return await JsonSerializer.DeserializeAsync<Item>(await response.Content.ReadAsStreamAsync());
}
return null;
}
|
PUT
|
public async Task UpdateItemAsync(Item item)
{
var itemJson = new StringContent(JsonSerializer.Serialize(item), Encoding.UTF8, "application/json");
var response = await _httpClient.PutAsync("api/item", itemJson);
if (!response.IsSuccessStatusCode)
{
var validationProblem = await JsonSerializer.DeserializeAsync<ValidationProblemDetails>(
await response.Content.ReadAsStreamAsync());
// validationProblem.Errors: key = property, values = error list
}
}
|
DELETE
|
public async Task DeleteItemAsync(int itemId)
{
var response = await _httpClient.DeleteAsync($"api/item/{itemId}");
if (!response.IsSuccessStatusCode)
{
var validationProblem = await JsonSerializer.DeserializeAsync<ValidationProblemDetails>(
await response.Content.ReadAsStreamAsync());
throw new HttpRequestException($"{validationProblem.Title}: {validationProblem.Detail}");
}
}
|
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
|
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();
}
|
Startup.cs
|
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
app.UseStatusCodePagesWithReExecute("/error/{0}");
app.UseExceptionHandler("/error/500");
app.UseMvc();
|
|
[HttpGet("/error/{code:int}")]
public string Error(int code)
{
return $"Error: {code}";
}
|
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;
}
}
|
|
OperationResult<MyDto, MyError> result;
if (/*...*/)
{
result = new OperationResult<MyDto, MyError>(myError);
}
else
{
result = new OperationResult<MyDto, MyError>(myDto);
}
return result;
|
By default .NET Core 3.0 uses System.Text.Json.
Use Newtonsoft.Json
|
dotnet add package Microsoft.AspNetCore.Mvc.NewtonsoftJson
|
Startup.cs
|
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers();
.AddNewtonsoftJson();
|
Startup.cs
|
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers(options =>
{
options.ModelBinderProviders.Insert(0, new CommaSeparatedModelBinderProvider());
});
|
CommaSeparatedModelBinderProvider.cs
|
public class CommaSeparatedModelBinderProvider : IModelBinderProvider
{
public IModelBinder GetBinder(ModelBinderProviderContext context)
{
if (context == null)
{
throw new ArgumentNullException(nameof(context));
}
if (context.Metadata.ModelType.GetInterface(nameof(IEnumerable)) != null
&& context.Metadata.ModelType != typeof(string))
{
return new BinderTypeModelBinder(typeof(CommaSeparatedModelBinder));
}
return null;
}
}
|
CommaSeparatedModelBinder.cs
|
public class CommaSeparatedModelBinder : IModelBinder
{
private static readonly MethodInfo ToArrayMethod = typeof(Enumerable).GetMethod("ToArray");
public Task BindModelAsync(ModelBindingContext bindingContext)
{
if (bindingContext == null)
{
throw new ArgumentNullException(nameof(bindingContext));
}
var modelType = bindingContext.ModelType;
var modelName = bindingContext.ModelName;
var valueType = modelType.GetElementType() ?? modelType.GetGenericArguments().FirstOrDefault();
if (modelType.GetInterface(nameof(IEnumerable)) != null
&& valueType != null
&& valueType.GetInterface(nameof(IConvertible)) != null)
{
var valueProviderResult = bindingContext.ValueProvider.GetValue(modelName);
if (valueProviderResult == ValueProviderResult.None)
{
return Task.CompletedTask;
}
bindingContext.ModelState.SetModelValue(modelName, valueProviderResult);
var value = valueProviderResult.FirstValue;
// Check if the argument value is null or empty
if (string.IsNullOrEmpty(value))
{
return Task.CompletedTask;
}
var list = (IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(valueType));
foreach (var splitValue in value.Split(new[] { ',' }))
{
if (!string.IsNullOrWhiteSpace(splitValue))
{
list.Add(Convert.ChangeType(splitValue, valueType));
}
}
var model = modelType.IsArray
? ToArrayMethod.MakeGenericMethod(valueType).Invoke(this, new[] { list })
: list;
bindingContext.Result = ModelBindingResult.Success(model);
}
return Task.CompletedTask;
}
}
|
Erreurs
Unsupported Media Type with Postman
- Body → raw = json à envoyer
- Headers → Content-Type = application/json
JsonSerializationException: Self referencing loop detected for property 'xxx' ...
Startup.cs
|
services.AddMvc()
// ignorer les références circulaires entre objets dans EF pour JSON
.AddJsonOptions(opt => opt.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore);
|
SqlException: Cannot insert explicit value for identity column in table 'xxx' when IDENTITY_INSERT is set to OFF
Impossible de sauvegarder les changements si une entité a été insérée avec une valeur ≠ default dans une propriété bindée avec une colonne Identity.
Solution: forcer la valeur de cette propriété a default. La bdd mettra la valeur de la propriété a jour après avoir inséré l'entité.