From c3a4bfbad3feadb375d087e3ed0d053b80acc5ef Mon Sep 17 00:00:00 2001 From: tacserus <115652334+tacserus@users.noreply.github.com> Date: Tue, 8 Oct 2024 16:41:36 +0500 Subject: [PATCH] web-api task --- Tests/Program.cs | 14 +- Tests/UsersApiTestsBase.cs | 4 +- .../Controllers/UsersController.cs | 262 +++++++++++++++++- WebApi.MinimalApi/Models/CreateUserDto.cs | 16 ++ WebApi.MinimalApi/Models/UpdateUserDto.cs | 13 + WebApi.MinimalApi/Program.cs | 43 ++- WebApi.MinimalApi/WebApi.MinimalApi.csproj | 2 +- 7 files changed, 336 insertions(+), 18 deletions(-) create mode 100644 WebApi.MinimalApi/Models/CreateUserDto.cs create mode 100644 WebApi.MinimalApi/Models/UpdateUserDto.cs diff --git a/Tests/Program.cs b/Tests/Program.cs index 54bdd72..eb60a92 100644 --- a/Tests/Program.cs +++ b/Tests/Program.cs @@ -9,13 +9,13 @@ public static void Main() var testsToRun = new string[] { typeof(Task1_GetUserByIdTests).FullName, - //typeof(Task2_CreateUserTests).FullName, - //typeof(Task3_UpdateUserTests).FullName, - //typeof(Task4_PartiallyUpdateUserTests).FullName, - //typeof(Task5_DeleteUserTests).FullName, - //typeof(Task6_HeadUserByIdTests).FullName, - //typeof(Task7_GetUsersTests).FullName, - //typeof(Task8_GetUsersOptionsTests).FullName, + typeof(Task2_CreateUserTests).FullName, + typeof(Task3_UpdateUserTests).FullName, + typeof(Task4_PartiallyUpdateUserTests).FullName, + typeof(Task5_DeleteUserTests).FullName, + typeof(Task6_HeadUserByIdTests).FullName, + typeof(Task7_GetUsersTests).FullName, + typeof(Task8_GetUsersOptionsTests).FullName, }; new AutoRun().Execute(new[] { 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..811e406 100644 --- a/WebApi.MinimalApi/Controllers/UsersController.cs +++ b/WebApi.MinimalApi/Controllers/UsersController.cs @@ -1,4 +1,9 @@ -using Microsoft.AspNetCore.Mvc; +using System.Text.RegularExpressions; +using AutoMapper; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.JsonPatch; +using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json; using WebApi.MinimalApi.Domain; using WebApi.MinimalApi.Models; @@ -8,20 +13,263 @@ namespace WebApi.MinimalApi.Controllers; [ApiController] public class UsersController : Controller { - // Чтобы ASP.NET положил что-то в userRepository требуется конфигурация - public UsersController(IUserRepository userRepository) + private readonly IUserRepository userRepository; + private readonly IMapper mapper; + private readonly LinkGenerator linkGenerator; + + public UsersController(IUserRepository userRepository, IMapper mapper, LinkGenerator linkGenerator) { + this.userRepository = userRepository; + this.mapper = mapper; + this.linkGenerator = linkGenerator; } - [HttpGet("{userId}")] + [HttpGet("{userId}", Name = nameof(GetUserById))] + [HttpHead("{userId}")] public ActionResult GetUserById([FromRoute] Guid userId) { - throw new NotImplementedException(); + var userEntity = userRepository.FindById(userId); + + if (userEntity is null) + return NotFound(); + + var userDto = mapper.Map(userEntity); + + if (Request.Method == "HEAD") + { + Response.Body = Stream.Null; + } + + return Ok(userDto); } [HttpPost] - public IActionResult CreateUser([FromBody] object user) + [Produces("application/json", "application/xml")] + public IActionResult CreateUser([FromBody] CreateUserDto user) + { + if (user is null) + { + return BadRequest(); + } + + if (user.Login == "" || user.Login is null) + { + ModelState.AddModelError("login", "Error"); + return UnprocessableEntity(ModelState); + } + + if (!user.Login.All(char.IsLetterOrDigit)) + { + ModelState.AddModelError("login", "Error"); + } + + if (!ModelState.IsValid) + { + return UnprocessableEntity(ModelState); + } + + var userToCreate = mapper.Map(user); + var createdUserEntity = userRepository.Insert(userToCreate); + + return CreatedAtRoute( + nameof(GetUserById), + new { userId = createdUserEntity.Id }, + createdUserEntity.Id); + } + + [HttpPut("{userId}")] + [Produces("application/json", "application/xml")] + public IActionResult UpdateUser([FromBody] UpdateUserDto user, [FromRoute] string userId) + { + if (user is null) + { + return BadRequest(); + } + + var validationResult = ValidateUserDto(user); + if (validationResult != null) + { + return validationResult; + } + + var userToUpdate = mapper.Map(user); + + if (!Guid.TryParse(userId, out var guid)) + { + return BadRequest(); + } + + var userEntity = userRepository.FindById(guid); + + if (userEntity == null) + { + var createdUserEntity = userRepository.Insert(userToUpdate); + + return CreatedAtRoute( + nameof(GetUserById), + new { userId = createdUserEntity.Id }, + createdUserEntity.Id); + } + + userRepository.Update(userToUpdate); + + return NoContent(); + } + + [HttpPatch("{userId}")] + + public IActionResult PartiallyUpdateUser([FromBody] JsonPatchDocument patchDoc, [FromRoute] String userId) + { + if (patchDoc == null) + { + return BadRequest(); + } + + var validationResult = ValidatePatchDocument(patchDoc); + if (validationResult != null) + { + return validationResult; + } + + if (!Guid.TryParse(userId, out var guid)) + { + return NotFound(); + } + + var user = userRepository.FindById(guid); + if (user == null) + { + return NotFound(); + } + + var updateUserDto = mapper.Map(user); + patchDoc.ApplyTo(updateUserDto, ModelState); + TryValidateModel(updateUserDto); + + return ModelState.IsValid ? NoContent() : UnprocessableEntity(ModelState); + } + + [HttpDelete("{userId}")] + public IActionResult DeleteUser(string userId) + { + if (!Guid.TryParse(userId, out var guid)) + { + return NotFound(); + } + + if (userRepository.FindById(guid) == null) + { + return NotFound(); + } + + userRepository.Delete(guid); + + return NoContent(); + } + + [HttpGet] + public IActionResult GetUsers([FromQuery] int pageNumber = 1, [FromQuery] int pageSize = 10) + { + 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 paginationHeader = new + { + previousPageLink = pageList.HasPrevious ? + linkGenerator.GetUriByAction( + HttpContext, nameof(GetUsers), + values: new { pageNumber = pageNumber - 1, pageSize }) : null, + nextPageLink = pageList.HasNext ? + linkGenerator.GetUriByAction( + HttpContext, nameof(GetUsers), + values: new { pageNumber = pageNumber + 1, pageSize }) : null, + totalCount = pageList.TotalCount, + pageSize = pageSize, + currentPage = pageNumber, + totalPages = (int)Math.Ceiling((double)pageList.TotalCount / pageSize) + }; + Response.Headers.Add("X-Pagination", JsonConvert.SerializeObject(paginationHeader)); + + return Ok(users); + } + + [HttpOptions] + public IActionResult Options() { - throw new NotImplementedException(); + Response.Headers.Add("Allow", "GET, POST, OPTIONS"); + + return Ok(); + } + + private IActionResult ValidateUserDto(UpdateUserDto userDto) + { + if (string.IsNullOrWhiteSpace(userDto.Login) || !userDto.Login.All(char.IsLetterOrDigit)) + { + ModelState.AddModelError("login", "Login must contain only letters and digits and cannot be empty."); + return UnprocessableEntity(ModelState); + } + + if (string.IsNullOrWhiteSpace(userDto.FirstName)) + { + ModelState.AddModelError("firstName", "First name cannot be empty."); + return UnprocessableEntity(ModelState); + } + + if (string.IsNullOrWhiteSpace(userDto.LastName)) + { + ModelState.AddModelError("lastName", "Last name cannot be empty."); + return UnprocessableEntity(ModelState); + } + + return null; + } + + private IActionResult ValidatePatchDocument(JsonPatchDocument patchDoc) + { + foreach (var operation in patchDoc.Operations) + { + if (operation.path == "login") + { + if (ContainsSpecialCharacters(operation.value.ToString())) + { + ModelState.AddModelError("login", "Login must not contain special characters."); + return UnprocessableEntity(ModelState); + } + if (string.IsNullOrWhiteSpace(operation.value.ToString())) + { + ModelState.AddModelError("login", "Login cannot be empty."); + return UnprocessableEntity(ModelState); + } + } + else if (operation.path == "firstName" && string.IsNullOrWhiteSpace(operation.value.ToString())) + { + ModelState.AddModelError("firstName", "First name cannot be empty."); + return UnprocessableEntity(ModelState); + } + else if (operation.path == "lastName" && string.IsNullOrWhiteSpace(operation.value.ToString())) + { + ModelState.AddModelError("lastName", "Last name cannot be empty."); + return UnprocessableEntity(ModelState); + } + } + return null; + } + + static bool ContainsSpecialCharacters(string str) + { + // регулярное выражение для проверки на наличие специальных символов + var pattern = @"[^a-zA-Z0-9а-яА-ЯёЁ]"; + + // Проверяем, соответствует ли строка шаблону + return Regex.IsMatch(str, pattern); } } \ No newline at end of file diff --git a/WebApi.MinimalApi/Models/CreateUserDto.cs b/WebApi.MinimalApi/Models/CreateUserDto.cs new file mode 100644 index 0000000..ee197e6 --- /dev/null +++ b/WebApi.MinimalApi/Models/CreateUserDto.cs @@ -0,0 +1,16 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; + +namespace WebApi.MinimalApi.Models; + +public class CreateUserDto +{ + [Required] + public string Login { get; set; } + + [DefaultValue("a")] + public string FirstName { get; set; } + + [DefaultValue("b")] + 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..7c90127 --- /dev/null +++ b/WebApi.MinimalApi/Models/UpdateUserDto.cs @@ -0,0 +1,13 @@ +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; } + public string FirstName { get; set; } + 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..6d13e51 100644 --- a/WebApi.MinimalApi/Program.cs +++ b/WebApi.MinimalApi/Program.cs @@ -1,10 +1,51 @@ +using System.Buffers; +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; + options.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes = true; + options.OutputFormatters.Add(new + XmlSerializerOutputFormatter()); + options.OutputFormatters.Insert(0, new + NewtonsoftJsonOutputFormatter(new JsonSerializerSettings + { + ContractResolver = new + CamelCasePropertyNamesContractResolver() + }, ArrayPool.Shared, options)); + }) .ConfigureApiBehaviorOptions(options => { options.SuppressModelStateInvalidFilter = true; options.SuppressMapClientErrors = true; + }) + .AddNewtonsoftJson(options => + { + options.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver(); }); +builder.Services.AddSingleton(); +builder.Services.AddAutoMapper(cfg => +{ + cfg.CreateMap() + .ForMember(dest => dest.FullName, + opt => opt.MapFrom(src => src.FirstName + " " + src.LastName)); + + cfg.CreateMap(); + cfg.CreateMap(); + cfg.CreateMap(); + +}, new System.Reflection.Assembly[0]); var app = builder.Build(); diff --git a/WebApi.MinimalApi/WebApi.MinimalApi.csproj b/WebApi.MinimalApi/WebApi.MinimalApi.csproj index 2089def..23c20a1 100644 --- a/WebApi.MinimalApi/WebApi.MinimalApi.csproj +++ b/WebApi.MinimalApi/WebApi.MinimalApi.csproj @@ -8,7 +8,7 @@ - +