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>();
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();
}
|
Client
Client/ItemClient.cs
|
public class ItemClient : IItemClient
{
private const string Path = "item";
private const string ApiVersion = "1";
private readonly HttpClient httpClient;
private readonly QueryBuilder queryBuilder;
// injection de HttpClient
public ItemDataService(HttpClient httpClient)
{
this.httpClient = httpClient;
queryBuilder = new QueryBuilder();
queryBuilder.Add("api-version", ApiVersion);
}
|
GET
ReadAsAsync
|
public async Task<IReadOnlyList<ItemDto>> GetAllItemsAsync(CancellationToken cancellationToken)
{
var uri = new Uri($"{Path}{queryBuilder.ToQueryString()}", UriKind.Relative);
var response = await this.httpClient.GetAsync(uri, cancellationToken);
response.EnsureSuccessStatusCode();
var items = await response.Content.ReadAsAsync<IReadOnlyList<ItemDto>>(cancellationToken);
return items;
}
|
|
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}");
}
}
|
Client/ItemClient.cs
|
this.httpClient.DefaultRequestHeaders.Add("ip", userIpAddress);
var response = await this.httpClient.PostAsync(uri, itemDtoJson);
|
Controllers/ItemController.cs
|
[HttpPost]
public async Task<IActionResult> AddAsync(
[FromBody] ItemDto itemDto,
[FromHeader] string ip) // doesn't seem to work
{
// work-around
var ip = this.HttpContext.Request.Headers["ip"].SingleOrDefault();
}
|
Startup.cs
|
public void ConfigureServices(IServiceCollection services)
{
services.AddHealthChecks();
// ...
public void Configure(IApplicationBuilder app)
{
app.UseEndpoints(endpoints =>
{
endpoints.MapHealthChecks("/health");
});
// ...
|
ItemViewModel.cs
|
public class ItemViewModel
{
public int Id { get; set; }
[Required]
[MinLength(6)]
public string Name { get; set; }
}
|
|
[HttpPost]
public IActionResult Post([FromBody]ItemViewModel itemVm)
{
// ce code n'est plus nécessaire car pris en charge par les automatic HTTP 400 responses
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var item = itemVm.ToEfItem();
_repository.Add(item);
if (_repository.SaveAll())
{
var newItemVm = new ItemViewModel(item);
return Created($"/api/ListApi/{newItemVm.Id}", newItemVm);
}
else
{
return BadRequest("Failed to save.");
}
|
L'attribut [ApiController] effectue automatiquement une validation du modèle et envoie une réponse HTTP 400 en cas d'erreur.
Il n'est donc plus nécessaire de tester ModelState.IsValid.
ASP.NET Core MVC utilise le filtre d'action ModelStateInvalidFilter pour tester la validité du modèle.
Exemple de réponse en cas d'erreur de validation du modèle:
|
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"traceId": "|b659be6b-4fc34f5e9f3caf17.",
"errors": {
"Name": [
"The Name field is required."
]
}
}
|
|
// déserialiser la réponse
var content = await response.Content.ReadAsStreamAsync();
var validationProblem = await JsonSerializer.DeserializeAsync<ValidationProblemDetails>(content);
|
|
dotnet add package FluentValidation.AspNetCore
|
Startup.cs
|
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers()
.AddFluentValidation();
services.AddTransient<IValidator<ItemDto>, ItemValidator>();
|
Validation/ItemValidator.cs
|
public class ItemValidator : AbstractValidator<ItemDto>
{
public ItemValidator()
{
RuleFor(x => x.Name).NotEmpty();
|
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();
}
|
Program.cs
|
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddTransient<IMyService, MyService>();
|
Images
Download
MyController.cs
|
[HttpGet]
public IActionResult Get()
{
var filePath = Path.Combine("~", "folder", "image.jpeg");
// VirtualFileResult
return File(filePath, "image/jpeg");
}
|
MyController.cs
|
[HttpGet]
public IActionResult Get()
{
var filePath = Path.Combine("~", "folder", "image.jpeg");
byte[] bytesArray = System.IO.File.ReadAllBytes(filePath);
return Ok("data:image/jpeg;base64," + Convert.ToBase64String(bytesArray));
}
|
MyController.cs
|
[HttpPost]
public async Task<IActionResult> Post([FromForm]IFormFile formFile)
{
var filePath = Path.Combine("/path/to/folder", formFile.FileName);
using (var stream = new FileStream(filePath, FileMode.Create))
{
await formFile.CopyToAsync(stream);
}
return Ok(filePath);
}
|
Postman → Body → form-data
- key: formFile
- File
- Value: Choose Files
|
Si formFile est toujours null, enlever [FromForm] |
Renvoyer le message d'erreur des exceptions
Startup.cs
|
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
// For Dev env
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage(); // displays exception type, error message, call stack and headers
app.UseExceptionHandler("/error-development"); // custom
}
else
{
app.UseExceptionHandler("/error");
}
|
Controllers/ErrorController.cs
|
[ApiExplorerSettings(IgnoreApi = true)] // exclude from Swagger / OpenAPI
[ApiController]
public class ErrorController : ControllerBase
{
[Route("/error")]
// default
// "title": "An error occured while processing your request."
// "status": 500, "traceId": "00-...-00"
public IActionResult Error() => Problem();
// additional properties
public IActionResult Error()
{
var exceptionHandlerFeature = HttpContext.Features.Get<IExceptionHandlerFeature>()!;
return Problem(detail: $"Exception type: {exceptionHandlerFeature.Error.GetType().Name}");
}
[Route("/error-development")]
public IActionResult ErrorLocalDevelopment([FromServices] IWebHostEnvironment webHostEnvironment)
{
if (!hostEnvironment.IsDevelopment())
{
return NotFound();
}
var exceptionHandlerFeature = HttpContext.Features.Get<IExceptionHandlerFeature>()!;
return Problem(
detail: exceptionHandlerFeature.Error.StackTrace,
title: exceptionHandlerFeature.Error.Message);
}
}
|
ExceptionFilterAttribute
ApiExceptionFilterAttribute.cs
|
public class ApiExceptionFilterAttribute : ExceptionFilterAttribute
{
public override void OnException(ExceptionContext context)
{
context.HttpContext.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
context.HttpContext.Response.ContentType = "text/plain";
context.HttpContext.Response.WriteAsync(context.Exception.Message).ConfigureAwait(false);
}
}
|
MyController.cs
|
[ApiExceptionFilter]
[Route("api/[controller]")]
public class MyController : Controller
|
ExceptionMiddleware.cs
|
public class ExceptionMiddleware
{
public async Task Invoke(HttpContext context)
{
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
var ex = context.Features.Get<IExceptionHandlerFeature>()?.Error;
if (ex == null) return;
context.Response.ContentType = "text/plain";
await context.Response.WriteAsync(ex.Message);
}
}
|
Startup.cs
|
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
app.UseExceptionHandler(new ExceptionHandlerOptions
{
ExceptionHandler = new ExceptionMiddleware().Invoke
});
app.UseMvc();
|
UseWhen et UseExceptionHandler
Startup.cs
|
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
app.UseWhen(x => x.Request.Path.Value.StartsWith("/api"), builder =>
{
builder.UseExceptionHandler(new ExceptionHandlerOptions
{
ExceptionHandler = new ExceptionMiddleware().Invoke
});
});
app.UseWhen(x => !x.Request.Path.Value.StartsWith("/api"), builder =>
{
builder.UseExceptionHandler("/Home/Error");
});
|
UseExceptionHandler
ExceptionMiddleware.cs
|
public class ExceptionMiddleware
{
public async Task Invoke(HttpContext context)
{
if (context.Request.Path.Value.StartsWith("/api"))
{
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
var ex = context.Features.Get<IExceptionHandlerFeature>()?.Error;
if (ex == null) return;
context.Response.ContentType = "text/plain";
await context.Response.WriteAsync(ex.Message).ConfigureAwait(false);
}
else
{
// redirect to /Home/Error
}
}
}
|
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é.