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/UsersApiTestsBase.cs b/Tests/UsersApiTestsBase.cs index 06705c5..855513d 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).ConfigureAwait(false); 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..142bfbf 100644 --- a/WebApi.MinimalApi/Controllers/UsersController.cs +++ b/WebApi.MinimalApi/Controllers/UsersController.cs @@ -1,4 +1,7 @@ -using Microsoft.AspNetCore.Mvc; +using AutoMapper; +using Microsoft.AspNetCore.JsonPatch; +using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json; using WebApi.MinimalApi.Domain; using WebApi.MinimalApi.Models; @@ -9,19 +12,171 @@ namespace WebApi.MinimalApi.Controllers; public class UsersController : Controller { // Чтобы ASP.NET положил что-то в userRepository требуется конфигурация - public UsersController(IUserRepository userRepository) + readonly private IUserRepository userRepository; + readonly private IMapper iMapper; + readonly private LinkGenerator linkGenerator; + public UsersController(IUserRepository userRepository, IMapper iMapper, LinkGenerator linkGenerator) { + this.userRepository = userRepository; + this.iMapper = iMapper; + this.linkGenerator = linkGenerator; } - [HttpGet("{userId}")] + [HttpHead("{userId}")] + [HttpGet("{userId}", Name = nameof(GetUserById))] + [Produces("application/json", "application/xml")] public ActionResult GetUserById([FromRoute] Guid userId) { - throw new NotImplementedException(); + var user = userRepository.FindById(userId); + if (user == null) + { + return NotFound(); + } + + if (Request.HttpContext.Request.Method != "HEAD") + { + return Ok(iMapper.Map(user)); + } + + Response.ContentLength = 0; + Response.ContentType = "application/json; charset=utf-8"; + return Ok(); } [HttpPost] - public IActionResult CreateUser([FromBody] object user) + [Produces("application/json", "application/xml")] + public IActionResult CreateUser([FromBody] PostUserDto? user) + { + if (user is null) + { + return BadRequest(); + } + + if (!ModelState.IsValid) + { + return UnprocessableEntity(ModelState); + } + + if (user.Login.Any(x => !char.IsLetterOrDigit(x))) + { + ModelState.AddModelError("login", "Should contain letters and numbers only"); + return UnprocessableEntity(ModelState); + } + + var createdUserEntity = userRepository.Insert(iMapper.Map(user)); + return CreatedAtRoute( + nameof(GetUserById), + new { userId = createdUserEntity.Id }, + createdUserEntity.Id); + } + + [HttpPut("{userId}")] + [Produces("application/json", "application/xml")] + public IActionResult UpdateUser([FromRoute] Guid userId, [FromBody] UpdateUserDto? user) + { + if (user is null) + { + return BadRequest(); + } + + if (userId == Guid.Empty) + { + return BadRequest(); + } + + if (!ModelState.IsValid) + { + return UnprocessableEntity(ModelState); + } + + userRepository.UpdateOrInsert(iMapper.Map(user, new UserEntity(userId)), out var updatedUserEntity); + if (updatedUserEntity) + { + return CreatedAtRoute( + nameof(GetUserById), + new { userId }, + userId); + } + + return NoContent(); + } + + [HttpPatch("{userId}")] + [Produces("application/json", "application/xml")] + public IActionResult PartiallyUpdateUser([FromRoute] Guid userId, [FromBody] JsonPatchDocument? patchDoc) + { + if (patchDoc is null) + { + return BadRequest(); + } + + var user = new UpdateUserDto(); + patchDoc.ApplyTo(user, ModelState); + if (userId == Guid.Empty) + { + return NotFound(); + } + + if (!TryValidateModel(user)) + { + return UnprocessableEntity(ModelState); + } + + var currentUser = userRepository.FindById(userId); + if (currentUser is null) + { + return NotFound(); + } + + userRepository.Update(iMapper.Map(user, new UserEntity(userId))); + return NoContent(); + } + + [HttpDelete("{userId}")] + [Produces("application/json", "application/xml")] + public IActionResult DeleteUser([FromRoute] Guid userId) + { + var currUser = userRepository.FindById(userId); + if (currUser is null) + { + return NotFound(); + } + + userRepository.Delete(userId); + return NoContent(); + } + + [HttpGet(Name = nameof(GetAllUsers))] + [Produces("application/json", "application/xml")] + public IActionResult GetAllUsers([FromQuery] int pageNumber = 1, [FromQuery] int pageSize = 10) + { + var realPageSize = pageSize > 20 ? 20 : pageSize; + var page = userRepository.GetPage(pageNumber, realPageSize); + + var paginationHeader = new + { + currentPage = page.CurrentPage == 0 ? 1 : page.CurrentPage, + totalPages = page.TotalPages, + pageSize = page.PageSize == 0 ? 1 : page.PageSize, + totalCount = page.TotalCount, + nextPageLink = page.HasNext + ? linkGenerator.GetUriByRouteValues(HttpContext, nameof(GetAllUsers), new { pageNumber = pageNumber + 1, pageSize } ) + : null, + previousPageLink = page.HasPrevious + ? linkGenerator.GetUriByRouteValues(HttpContext, nameof(GetAllUsers), new { pageNumber = pageNumber - 1, pageSize } ) + : null, + }; + + Response.Headers.Append("X-Pagination", JsonConvert.SerializeObject(paginationHeader)); + return Ok(iMapper.Map>(page)); + } + + [HttpOptions] + [Produces("application/json", "application/xml")] + public IActionResult Options() { - throw new NotImplementedException(); + Response.Headers.Append("Allow", new[] { "GET", "POST", "OPTIONS" }); + Response.ContentLength = 0; + return Ok(); } } \ No newline at end of file diff --git a/WebApi.MinimalApi/Models/PostUserDto.cs b/WebApi.MinimalApi/Models/PostUserDto.cs new file mode 100644 index 0000000..01ae12c --- /dev/null +++ b/WebApi.MinimalApi/Models/PostUserDto.cs @@ -0,0 +1,16 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; + +namespace WebApi.MinimalApi.Models; + +public class PostUserDto +{ + [Required] + 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/UpdateUserDto.cs b/WebApi.MinimalApi/Models/UpdateUserDto.cs new file mode 100644 index 0000000..4f200d3 --- /dev/null +++ b/WebApi.MinimalApi/Models/UpdateUserDto.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; + +namespace WebApi.MinimalApi.Models; + +public class UpdateUserDto +{ + [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..5f9c981 100644 --- a/WebApi.MinimalApi/Program.cs +++ b/WebApi.MinimalApi/Program.cs @@ -1,11 +1,40 @@ +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 XmlDataContractSerializerOutputFormatter()); + // Эта настройка позволяет отвечать кодом 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(dto => dto.FullName, + opt => opt.MapFrom(user => $"{user.LastName} {user.FirstName}")); + cfg.CreateMap(); + cfg.CreateMap(); + cfg.CreateMap, PageList>(); +}, new System.Reflection.Assembly[0]); +builder.Services.AddSingleton(); var app = builder.Build(); app.MapControllers(); 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