diff --git a/Tests/Task5_DeleteUserTests.cs b/Tests/Task5_DeleteUserTests.cs index 2851605..168ca5e 100644 --- a/Tests/Task5_DeleteUserTests.cs +++ b/Tests/Task5_DeleteUserTests.cs @@ -65,7 +65,7 @@ public async Task Test4_Code404_WhenAlreadyCreatedAndDeleted() lastName = "Condenado" }); - DeleteUser(createdUserId); + await DeleteUser(createdUserId); var request = new HttpRequestMessage(); request.Method = HttpMethod.Delete; diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj index b2a360a..c53e3a7 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -16,7 +16,7 @@ - + diff --git a/Tests/UsersApiTestsBase.cs b/Tests/UsersApiTestsBase.cs index 06705c5..6bb853b 100644 --- a/Tests/UsersApiTestsBase.cs +++ b/Tests/UsersApiTestsBase.cs @@ -96,13 +96,13 @@ protected async Task CreateUser(object user) return createdUserId; } - protected void DeleteUser(string userId) + protected async Task DeleteUser(string userId) { var request = new HttpRequestMessage(); request.Method = HttpMethod.Delete; request.RequestUri = BuildUsersByIdUri(userId); request.Headers.Add("Accept", "*/*"); - var response = HttpClient.Send(request); + var response = await HttpClient.SendAsync(request); response.StatusCode.Should().Be(HttpStatusCode.NoContent); response.ShouldNotHaveHeader("Content-Type"); diff --git a/WebApi.MinimalApi/Controllers/UsersController.cs b/WebApi.MinimalApi/Controllers/UsersController.cs index e6720ca..4f62568 100644 --- a/WebApi.MinimalApi/Controllers/UsersController.cs +++ b/WebApi.MinimalApi/Controllers/UsersController.cs @@ -1,6 +1,12 @@ -using Microsoft.AspNetCore.Mvc; +using System.Net.Mime; +using AutoMapper; +using Microsoft.AspNetCore.JsonPatch; +using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json; +using Swashbuckle.AspNetCore.Annotations; using WebApi.MinimalApi.Domain; using WebApi.MinimalApi.Models; +using WebApi.MinimalApi.Samples; namespace WebApi.MinimalApi.Controllers; @@ -8,20 +14,202 @@ namespace WebApi.MinimalApi.Controllers; [ApiController] public class UsersController : Controller { + private readonly IUserRepository userRepository; + private readonly IMapper mapper; + private readonly LinkGenerator linkGenerator; + private ISwaggerDescriptionsForUsersController swaggerDescriptionsForUsersControllerImplementation; + // Чтобы ASP.NET положил что-то в userRepository требуется конфигурация - public UsersController(IUserRepository userRepository) + public UsersController(IUserRepository userRepository, IMapper mapper, LinkGenerator linkGenerator) { + this.userRepository = userRepository; + this.mapper = mapper; + this.linkGenerator = linkGenerator; } - [HttpGet("{userId}")] - public ActionResult GetUserById([FromRoute] Guid userId) + [HttpGet("{userId:guid}", Name = nameof(GetUserById))] + [HttpHead("{userId}")] + [Produces("application/json", "application/xml")] + [SwaggerResponse(200, "OK", typeof(UserDto))] + [SwaggerResponse(404, "Пользователь не найден")] + public ActionResult GetUserById(Guid userId) { - throw new NotImplementedException(); + var user = userRepository.FindById(userId); + if (user is null) + return NotFound(); + + return Ok(mapper.Map(user)); } [HttpPost] - public IActionResult CreateUser([FromBody] object user) + [Consumes("application/json")] + [Produces("application/json", "application/xml")] + [SwaggerResponse(201, "Пользователь создан")] + [SwaggerResponse(400, "Некорректные входные данные")] + [SwaggerResponse(422, "Ошибка при проверке")] + public IActionResult CreateUser([FromBody] UserToCreateDto user) + { + if (user is null) + { + return BadRequest(); + } + + if (!ModelState.IsValid) + { + return UnprocessableEntity(ModelState); + } + + var userEntity = userRepository.Insert(mapper.Map(user)); + + return CreatedAtRoute( + nameof(GetUserById), + new { userId = userEntity.Id }, + userEntity.Id); + } + + [HttpPut("{userId}")] + [Consumes("application/json")] + [Produces("application/json", "application/xml")] + [SwaggerResponse(201, "Пользователь создан")] + [SwaggerResponse(204, "Пользователь обновлен")] + [SwaggerResponse(400, "Некорректные входные данные")] + [SwaggerResponse(422, "Ошибка при проверке")] + public IActionResult UpdateUser(UserToUpdateDto? userToUpdateDto, string userId) + { + if (!Guid.TryParse(userId, out var userIdGuid) || userToUpdateDto is null) + { + return BadRequest(); + } + if (!ModelState.IsValid) + { + return UnprocessableEntity(ModelState); + } + + var userEntity = mapper.Map(userToUpdateDto, new UserEntity(userIdGuid)); + + userRepository.UpdateOrInsert(userEntity, out var isInserted); + + return isInserted + ? CreatedAtRoute( + nameof(GetUserById), + new { userId = userEntity.Id }, + userEntity.Id) + : NoContent(); + } + + [HttpPatch("{userId:guid}")] + [Consumes("application/json-patch+json")] + [Produces("application/json", "application/xml")] + [SwaggerResponse(204, "Пользователь обновлен")] + [SwaggerResponse(400, "Некорректные входные данные")] + [SwaggerResponse(404, "Пользователь не найден")] + [SwaggerResponse(422, "Ошибка при проверке")] + public IActionResult PartiallyUpdateUser([FromBody] JsonPatchDocument patchDoc, Guid userId) + { + if (!ModelState.IsValid) + { + return BadRequest(); + } + + var user = userRepository.FindById(userId); + + if (user is null) + { + return NotFound(); + } + + var userToUpdateDto = mapper.Map(user); + + + patchDoc.ApplyTo(userToUpdateDto, ModelState); + TryValidateModel(userToUpdateDto); + + if (!ModelState.IsValid) + { + return UnprocessableEntity(ModelState); + } + + var updatedUserEntity = mapper.Map(userToUpdateDto, new UserEntity(userId)); + userRepository.Update(updatedUserEntity); + + return NoContent(); + } + + [HttpDelete("{userId:guid}")] + [Produces("application/json", "application/xml")] + [SwaggerResponse(204, "Пользователь удален")] + [SwaggerResponse(404, "Пользователь не найден")] + public IActionResult DeleteUser(Guid userId) + { + if (userRepository.FindById(userId) is null) + { + return NotFound(); + } + + userRepository.Delete(userId); + + return NoContent(); + } + + [HttpHead("{userId:guid}")] + [HttpHead("{userId}")] + [Produces("application/json", "application/xml")] + [SwaggerResponse(200, "OK", typeof(UserDto))] + [SwaggerResponse(404, "Пользователь не найден")] + public IActionResult Head(Guid userId) + { + var user = userRepository.FindById(userId); + if (user is null) + return NotFound(); + + HttpContext.Response.ContentType = "application/json; charset=utf-8"; + return Ok(); + } + + [HttpGet(Name = nameof(GetUsers))] + [Produces("application/json", "application/xml")] + [ProducesResponseType(typeof(IEnumerable), 200)] + public IActionResult GetUsers([FromQuery] int pageNumber = 1, [FromQuery] int pageSize = 10) { - throw new NotImplementedException(); + if (pageNumber < 1) pageNumber = 1; + if (pageSize < 1) pageSize = 1; + if (pageSize > 20) pageSize = 20; + + var pageList = userRepository.GetPage(pageNumber, pageSize); + var users = mapper.Map>(pageList); + + var totalCount = pageList.TotalCount; + var totalPages = (int)Math.Ceiling((double)totalCount / pageSize); + + var paginationHeader = new + { + previousPageLink = pageNumber > 1 ? linkGenerator + .GetUriByRouteValues( + HttpContext, + "GetUsers", + new + { + pageNumber = pageNumber - 1, + pageSize + }) + : null, + nextPageLink = linkGenerator.GetUriByRouteValues(HttpContext, "GetUsers", new { pageNumber = pageNumber + 1, pageSize }), + totalCount = totalCount, + pageSize = pageSize, + currentPage = pageNumber, + totalPages = totalPages + }; + + Response.Headers.Append("X-Pagination", JsonConvert.SerializeObject(paginationHeader)); + return Ok(users); + } + + [HttpOptions] + [SwaggerResponse(200, "OK")] + public IActionResult Options() + { + Response.Headers.Append("Allow", "POST, GET, OPTIONS"); + + return Ok(); } } \ No newline at end of file diff --git a/WebApi.MinimalApi/Models/UserToCreateDto.cs b/WebApi.MinimalApi/Models/UserToCreateDto.cs new file mode 100644 index 0000000..0ac6002 --- /dev/null +++ b/WebApi.MinimalApi/Models/UserToCreateDto.cs @@ -0,0 +1,17 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; + +namespace WebApi.MinimalApi.Models; + +public class UserToCreateDto +{ + [Required] + [RegularExpression("^[0-9\\p{L}]*$", ErrorMessage = "Login should contain only letters or digits")] + public string Login { get; set; } + + [DefaultValue("John")] + public string FirstName { get; set; } + + [DefaultValue("Doe")] + public string LastName { get; set; } +} \ No newline at end of file diff --git a/WebApi.MinimalApi/Models/UserToUpdateDto.cs b/WebApi.MinimalApi/Models/UserToUpdateDto.cs new file mode 100644 index 0000000..7d9120a --- /dev/null +++ b/WebApi.MinimalApi/Models/UserToUpdateDto.cs @@ -0,0 +1,17 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; + +namespace WebApi.MinimalApi.Models; + +public class UserToUpdateDto +{ + [Required] + [RegularExpression("^[0-9\\p{L}]*$", ErrorMessage = "Login should contain only letters or digits")] + public string Login { get; set; } + + [Required] + public string FirstName { get; set; } + + [Required] + public string LastName { get; set; } +} \ No newline at end of file diff --git a/WebApi.MinimalApi/Program.cs b/WebApi.MinimalApi/Program.cs index e824d81..6846798 100644 --- a/WebApi.MinimalApi/Program.cs +++ b/WebApi.MinimalApi/Program.cs @@ -1,11 +1,50 @@ +using System.Buffers; +using System.Reflection; +using Microsoft.AspNetCore.Mvc.Formatters; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; +using WebApi.MinimalApi.Domain; +using WebApi.MinimalApi.Models; + var builder = WebApplication.CreateBuilder(args); builder.WebHost.UseUrls("http://localhost:5000"); -builder.Services.AddControllers() +builder.Services.AddControllers(options => + { + // Этот OutputFormatter позволяет возвращать данные в XML, если требуется. + options.OutputFormatters.Add(new XmlSerializerOutputFormatter()); + options.OutputFormatters.Insert(0, new + NewtonsoftJsonOutputFormatter(new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver() + }, ArrayPool.Shared, options)); + // Эта настройка позволяет отвечать кодом 406 Not Acceptable на запросы неизвестных форматов. + options.ReturnHttpNotAcceptable = true; + // Эта настройка приводит к игнорированию заголовка Accept, когда он содержит */* + // Здесь она нужна, чтобы в этом случае ответ возвращался в формате JSON + options.RespectBrowserAcceptHeader = true; + }) .ConfigureApiBehaviorOptions(options => { options.SuppressModelStateInvalidFilter = true; options.SuppressMapClientErrors = true; + }) + .AddNewtonsoftJson(options => + { + options.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver(); + options.SerializerSettings.DefaultValueHandling = DefaultValueHandling.Populate; }); +builder.Services.AddAutoMapper(cfg => +{ + cfg.CreateMap() + .ForMember(dest => dest.FullName, opt => opt.MapFrom(src => $"{src.LastName} {src.FirstName}")); + + cfg.CreateMap(); + cfg.CreateMap(); + cfg.CreateMap(); +}, Array.Empty()); + +builder.Services.AddSingleton(); + var app = builder.Build(); app.MapControllers(); diff --git a/WebApi.MinimalApi/WebApi.MinimalApi.csproj b/WebApi.MinimalApi/WebApi.MinimalApi.csproj index 2089def..b73d4c9 100644 --- a/WebApi.MinimalApi/WebApi.MinimalApi.csproj +++ b/WebApi.MinimalApi/WebApi.MinimalApi.csproj @@ -21,6 +21,7 @@ + diff --git a/web-api.sln.DotSettings b/web-api.sln.DotSettings index b5ef7ae..3744498 100644 --- a/web-api.sln.DotSettings +++ b/web-api.sln.DotSettings @@ -19,6 +19,10 @@ <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"><ExtraRule Prefix="" Suffix="" Style="aaBb" /></Policy> <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb_AaBb" /> + <Policy><Descriptor Staticness="Static" AccessRightKinds="Private" Description="Static readonly fields (private)"><ElementKinds><Kind Name="READONLY_FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"><ExtraRule Prefix="" Suffix="" Style="aaBb" /></Policy></Policy> + <Policy><Descriptor Staticness="Instance" AccessRightKinds="Private" Description="Instance fields (private)"><ElementKinds><Kind Name="FIELD" /><Kind Name="READONLY_FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /></Policy> + <Policy><Descriptor Staticness="Any" AccessRightKinds="Any" Description="Types and namespaces"><ElementKinds><Kind Name="NAMESPACE" /><Kind Name="CLASS" /><Kind Name="STRUCT" /><Kind Name="ENUM" /><Kind Name="DELEGATE" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb_AaBb" /></Policy> + <Policy><Descriptor Staticness="Static" AccessRightKinds="Private" Description="Static fields (private)"><ElementKinds><Kind Name="FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /></Policy> <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> @@ -69,4 +73,5 @@ True True True + True True \ No newline at end of file