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/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..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..d039865 100644 --- a/WebApi.MinimalApi/Controllers/UsersController.cs +++ b/WebApi.MinimalApi/Controllers/UsersController.cs @@ -1,27 +1,226 @@ -using Microsoft.AspNetCore.Mvc; +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; [Route("api/[controller]")] [ApiController] -public class UsersController : Controller +[SwaggerTag("Операции с пользователями")] +public class UsersController : Controller, ISwaggerDescriptionsForUsersController { // Чтобы ASP.NET положил что-то в userRepository требуется конфигурация - public UsersController(IUserRepository userRepository) + private IUserRepository Repository; + private IMapper Mapper; + private readonly LinkGenerator linkGenerator; + public UsersController(IUserRepository userRepository, IMapper mapper, LinkGenerator linkGenerator) { + Repository = userRepository; + Mapper = mapper; + this.linkGenerator = linkGenerator; } - [HttpGet("{userId}")] + /// + /// Получить пользователя + /// + /// Идентификатор пользователя + [HttpGet("{userId}", Name = nameof(GetUserById))] + [HttpHead("{userId}")] + [Produces("application/json", "application/xml")] + [SwaggerResponse(200, "OK", typeof(UserDto))] + [SwaggerResponse(404, "Пользователь не найден")] public ActionResult GetUserById([FromRoute] Guid userId) { - throw new NotImplementedException(); + var user = Repository.FindById(userId); + if (user is null) + return NotFound(); + + var result = Mapper.Map(user); + + if (HttpContext.Request.Method != "HEAD") + return Ok(result); + + Response.ContentType = "application/json; charset=utf-8"; + return Ok(); + } + /// + /// Создать пользователя + /// + /// + /// Пример запроса: + /// + /// POST /api/users + /// { + /// "login": "johndoe375", + /// "firstName": "John", + /// "lastName": "Doe" + /// } + /// + /// + /// Данные для создания пользователя [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] AddUserDto user) + { + var createdUserEntity = Mapper.Map(user); + if (createdUserEntity is null) + return BadRequest(); + if(createdUserEntity.Login?.All(char.IsLetterOrDigit) == false) + ModelState.AddModelError(nameof(createdUserEntity.Login).ToLower(), "Login must be letters or digits"); + if (!ModelState.IsValid) + return UnprocessableEntity(ModelState); + var createdUser = Repository.Insert(createdUserEntity); + return CreatedAtRoute( + nameof(GetUserById), + new { userId = createdUser.Id }, + createdUser.Id); + } + + /// + /// Обновить пользователя + /// + /// Идентификатор пользователя + /// Обновленные данные пользователя + [HttpPut("{userId}")] + [Consumes("application/json")] + [Produces("application/json", "application/xml")] + [SwaggerResponse(201, "Пользователь создан")] + [SwaggerResponse(204, "Пользователь обновлен")] + [SwaggerResponse(400, "Некорректные входные данные")] + [SwaggerResponse(422, "Ошибка при проверке")] + public IActionResult UpdateUser(Guid userId, [FromBody] UpdateUserDto user) + { + if (user == null || userId == Guid.Empty) + return BadRequest(); + if (!ModelState.IsValid) + return UnprocessableEntity(ModelState); + + var userEntity = Mapper.Map(user, new UserEntity(userId)); + + Repository.UpdateOrInsert(userEntity, out var isInsert); + + if (isInsert) + return CreatedAtRoute( + nameof(GetUserById), + new {userId = userId}, + userId); + return NoContent(); + } + + /// + /// Частично обновить пользователя + /// + /// Идентификатор пользователя + /// JSON Patch для пользователя + [HttpPatch("{userId}")] + [Consumes("application/json-patch+json")] + [Produces("application/json", "application/xml")] + [SwaggerResponse(204, "Пользователь обновлен")] + [SwaggerResponse(400, "Некорректные входные данные")] + [SwaggerResponse(404, "Пользователь не найден")] + [SwaggerResponse(422, "Ошибка при проверке")] + public IActionResult PartiallyUpdateUser(Guid userId, [FromBody] JsonPatchDocument patchDoc) + { + if (patchDoc == null) + return BadRequest(); + + var user = Repository.FindById(userId); + if (user == null || userId == Guid.Empty) + return NotFound(); + + var updateUserDto = Mapper.Map(user, new UpdateUserDto()); + + patchDoc.ApplyTo(updateUserDto, ModelState); + + TryValidateModel(updateUserDto); + + if (!ModelState.IsValid) + return UnprocessableEntity(ModelState); + + Mapper.Map(updateUserDto, user); + + Repository.Update(user); + + return NoContent(); + } + + /// + /// Удалить пользователя + /// + /// Идентификатор пользователя + [HttpDelete("{userId}")] + [Produces("application/json", "application/xml")] + [SwaggerResponse(204, "Пользователь удален")] + [SwaggerResponse(404, "Пользователь не найден")] + public IActionResult DeleteUser(Guid userId) + { + var user = Repository.FindById(userId); + if (user == null) + { + return NotFound(); + } + + Repository.Delete(userId); + + return NoContent(); + } + + /// + /// Получить пользователей + /// + /// Номер страницы, по умолчанию 1 + /// Размер страницы, по умолчанию 20 + /// OK + [HttpGet(Name = nameof(GetUsers))] + [Produces("application/json", "application/xml")] + [ProducesResponseType(typeof(IEnumerable), 200)] + public IActionResult GetUsers(int pageNumber = 1, int pageSize = 10) + { + if (pageNumber < 1) + pageNumber = 1; + if (pageSize < 1) + pageSize = 1; + if (pageSize > 20) + pageSize = 20; + + var pageList = Repository.GetPage(pageNumber, pageSize); + var users = Mapper.Map>(pageList); + + var paginationHeader = new + { + previousPageLink = pageList.HasPrevious ? linkGenerator.GetUriByRouteValues(HttpContext, nameof(GetUsers), new { pageNumber = pageNumber - 1, pageSize }) : null, + nextPageLink = pageList.HasNext ? linkGenerator.GetUriByRouteValues(HttpContext, nameof(GetUsers), new { pageNumber = pageNumber + 1, pageSize }) : null, + totalCount = pageList.TotalCount, + pageSize = pageList.PageSize, + currentPage = pageList.CurrentPage, + totalPages = pageList.TotalPages + }; + + Response.Headers.Add("X-Pagination", JsonConvert.SerializeObject(paginationHeader)); + + return Ok(users); + } + + /// + /// Опции по запросам о пользователях + /// + [HttpOptions] + [SwaggerResponse(200, "OK")] + public IActionResult GetUsersOptions() { - throw new NotImplementedException(); + Response.Headers.Add("Allow", "POST, GET, OPTIONS"); + + return Ok(); } } \ No newline at end of file diff --git a/WebApi.MinimalApi/Models/AddUserDto.cs b/WebApi.MinimalApi/Models/AddUserDto.cs new file mode 100644 index 0000000..014854f --- /dev/null +++ b/WebApi.MinimalApi/Models/AddUserDto.cs @@ -0,0 +1,14 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; + +namespace WebApi.MinimalApi.Models; + +public class AddUserDto +{ + [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..c14e081 --- /dev/null +++ b/WebApi.MinimalApi/Models/UpdateUserDto.cs @@ -0,0 +1,15 @@ +using System.ComponentModel; +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..bb9a3f7 100644 --- a/WebApi.MinimalApi/Program.cs +++ b/WebApi.MinimalApi/Program.cs @@ -1,13 +1,50 @@ +using Microsoft.AspNetCore.Mvc.Formatters; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; +using WebApi.MinimalApi.Domain; +using WebApi.MinimalApi.Models; +using WebApi.MinimalApi.Samples; + var builder = WebApplication.CreateBuilder(args); builder.WebHost.UseUrls("http://localhost:5000"); builder.Services.AddControllers() .ConfigureApiBehaviorOptions(options => { options.SuppressModelStateInvalidFilter = true; options.SuppressMapClientErrors = true; + }) + .AddNewtonsoftJson(options => + { + options.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver(); + options.SerializerSettings.DefaultValueHandling = DefaultValueHandling.Populate; }); +builder.Services.AddSingleton(); +builder.Services.AddControllers(options => + { + options.OutputFormatters.Add(new XmlDataContractSerializerOutputFormatter()); + options.ReturnHttpNotAcceptable = true; + options.RespectBrowserAcceptHeader = true; + }) + .ConfigureApiBehaviorOptions(options => + { + options.SuppressModelStateInvalidFilter = true; + options.SuppressMapClientErrors = true; + }); +builder.Services.AddAutoMapper(cfg => +{ + cfg.CreateMap() + .ForMember(dest => dest.FullName, opt => opt.MapFrom(src => $"{src.LastName} {src.FirstName}")); + cfg.CreateMap(); + cfg.CreateMap(); + cfg.CreateMap(); + +}, new System.Reflection.Assembly[0]); + +builder.Services.AddSwaggerGeneration(); + var app = builder.Build(); app.MapControllers(); +app.UseSwaggerWithUI(); app.Run(); \ No newline at end of file diff --git a/WebApi.MinimalApi/Samples/ISwaggerDescriptionsForUsersController.cs b/WebApi.MinimalApi/Samples/ISwaggerDescriptionsForUsersController.cs index b157bd5..9457287 100644 --- a/WebApi.MinimalApi/Samples/ISwaggerDescriptionsForUsersController.cs +++ b/WebApi.MinimalApi/Samples/ISwaggerDescriptionsForUsersController.cs @@ -39,7 +39,7 @@ public interface ISwaggerDescriptionsForUsersController [SwaggerResponse(201, "Пользователь создан")] [SwaggerResponse(400, "Некорректные входные данные")] [SwaggerResponse(422, "Ошибка при проверке")] - IActionResult CreateUser([FromBody] object user); + IActionResult CreateUser([FromBody] AddUserDto user); /// /// Удалить пользователя @@ -63,7 +63,7 @@ public interface ISwaggerDescriptionsForUsersController [SwaggerResponse(204, "Пользователь обновлен")] [SwaggerResponse(400, "Некорректные входные данные")] [SwaggerResponse(422, "Ошибка при проверке")] - IActionResult UpdateUser([FromRoute] Guid userId, [FromBody] object user); + IActionResult UpdateUser([FromRoute] Guid userId, [FromBody] UpdateUserDto user); /// /// Частично обновить пользователя @@ -78,7 +78,7 @@ public interface ISwaggerDescriptionsForUsersController [SwaggerResponse(404, "Пользователь не найден")] [SwaggerResponse(422, "Ошибка при проверке")] IActionResult PartiallyUpdateUser([FromRoute] Guid userId, - [FromBody] JsonPatchDocument patchDoc); + [FromBody] JsonPatchDocument patchDoc); /// /// Получить пользователей diff --git a/WebApi.MinimalApi/WebApi.MinimalApi.csproj b/WebApi.MinimalApi/WebApi.MinimalApi.csproj index 2089def..32cbb21 100644 --- a/WebApi.MinimalApi/WebApi.MinimalApi.csproj +++ b/WebApi.MinimalApi/WebApi.MinimalApi.csproj @@ -6,9 +6,17 @@ enable 11 + + + bin\Debug\WebApi.MinimalApi.xml + + + + bin\Release\WebApi.MinimalApi.xml + - + @@ -21,6 +29,7 @@ +