Merge branch 'unstable' into 'main'

Swagger-docs Update!

See merge request internship-2025/survey-webapp/survey-webapp!10
This commit is contained in:
Вячеслав 2025-04-27 11:10:50 +00:00
commit 64a10ac32d
20 changed files with 287 additions and 18 deletions

View file

@ -1,19 +1,31 @@
using System.Security.Claims; using System.Security.Claims;
using SurveyBackend.Core.Contexts; using SurveyBackend.Core.Contexts;
using SurveyBackend.Services.Exceptions;
namespace SurveyBackend.Contexts; namespace SurveyBackend.Contexts;
/// <summary>
/// Упрощает получение UserId из JWT-токена
/// </summary>
public class UserContext : IUserContext public class UserContext : IUserContext
{ {
private readonly IHttpContextAccessor _httpContextAccessor; private readonly IHttpContextAccessor _httpContextAccessor;
/// <summary>
/// Добавьте HttpContextAccessor в DI и будет счастье
/// </summary>
/// <param name="httpContextAccessor"></param>
public UserContext(IHttpContextAccessor httpContextAccessor) public UserContext(IHttpContextAccessor httpContextAccessor)
{ {
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
} }
/// <summary>
/// Возвращает UserId из токена, при отсуствии кидает Unauthorized
/// </summary>
/// <exception cref="UnauthorizedAccessException"></exception>
public int UserId => public int UserId =>
int.Parse( int.Parse(
_httpContextAccessor.HttpContext?.User.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier) _httpContextAccessor.HttpContext?.User.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier)
?.Value ?? throw new UnauthorizedAccessException()); ?.Value ?? throw new UnauthorizedException("Where's your token mister"));
} }

View file

@ -6,17 +6,32 @@ using IAuthorizationService = SurveyBackend.Core.Services.IAuthorizationService;
namespace SurveyBackend.Controllers; namespace SurveyBackend.Controllers;
/// <summary>
/// Контроллер для всего связанного с авторизацией пользователей
/// </summary>
[ApiController] [ApiController]
[Route("api/auth")] [Route("api/auth")]
public class AuthController : ControllerBase public class AuthController : ControllerBase
{ {
private readonly IAuthorizationService _authorizationService; private readonly IAuthorizationService _authorizationService;
/// <summary>
/// Нет ну вы прикалываетесь что ли мне ща каждый контроллер описывать?
/// </summary>
/// <param name="authorizationService"></param>
public AuthController(IAuthorizationService authorizationService) public AuthController(IAuthorizationService authorizationService)
{ {
_authorizationService = authorizationService; _authorizationService = authorizationService;
} }
/// <summary>
/// Авторизация
/// </summary>
/// <remarks>Принимает на вход email и password. При отсутствии такого email вернет 404, при неправильном пароле 401, при успехе 200 и валидный токен</remarks>
/// <param name="loginData"></param>
/// <response code="200">Success: Возвращает токен</response>
/// <response code="401">Unauthorized: Неправильный пароль</response>
/// <returns></returns>
[AllowAnonymous] [AllowAnonymous]
[HttpPost("login")] [HttpPost("login")]
[ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status401Unauthorized)]
@ -27,6 +42,12 @@ public class AuthController : ControllerBase
return Ok(new { token = token }); return Ok(new { token = token });
} }
/// <summary>
/// Регистрация
/// </summary>
/// <remarks>Принимает на вход кучу всяких полей, потом разберемся</remarks>
/// <param name="registerData"></param>
/// <returns></returns>
[AllowAnonymous] [AllowAnonymous]
[HttpPost("register")] [HttpPost("register")]
[ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status401Unauthorized)]

View file

@ -7,17 +7,25 @@ using SurveyLib.Core.Services;
namespace SurveyBackend.Controllers; namespace SurveyBackend.Controllers;
/// <inheritdoc />
[ApiController] [ApiController]
[Route("api/surveys/{surveyId}/questions")] [Route("api/surveys/{surveyId}/questions")]
public class QuestionController : ControllerBase public class QuestionController : ControllerBase
{ {
private readonly IQuestionService _questionService; private readonly IQuestionService _questionService;
/// <inheritdoc />
public QuestionController(IQuestionService questionService, IUserContext userContext) public QuestionController(IQuestionService questionService, IUserContext userContext)
{ {
_questionService = questionService; _questionService = questionService;
} }
/// <summary>
/// Возвращает список вопросов из опроса по его ID
/// </summary>
/// <remarks>Получение вопросов по ID опроса. В случае отсутствия опроса с таким идентификатором выкидывает 404</remarks>
/// <param name="surveyId"></param>
/// <returns></returns>
[AllowAnonymous] [AllowAnonymous]
[HttpGet] [HttpGet]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@ -29,6 +37,13 @@ public class QuestionController : ControllerBase
return Ok(result); return Ok(result);
} }
/// <summary>
/// Добавить вопрос к опросу
/// </summary>
/// <remarks>К опросу с указанным ID добавляет вопрос. Если я правильно написал, при отсутствии такого опроса кинет 404</remarks>
/// <param name="dto"></param>
/// <param name="surveyId"></param>
/// <returns></returns>
[Authorize] [Authorize]
[HttpPost] [HttpPost]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]

View file

@ -10,6 +10,7 @@ using SurveyLib.Core.Services;
namespace SurveyBackend.Controllers; namespace SurveyBackend.Controllers;
/// <inheritdoc />
[ApiController] [ApiController]
[Route("api/surveys")] [Route("api/surveys")]
public class SurveyController : ControllerBase public class SurveyController : ControllerBase
@ -17,12 +18,18 @@ public class SurveyController : ControllerBase
private readonly ISurveyService _surveyService; private readonly ISurveyService _surveyService;
private readonly IUserContext _userContext; private readonly IUserContext _userContext;
/// <inheritdoc />
public SurveyController(ISurveyService surveyService, IUserContext userContext) public SurveyController(ISurveyService surveyService, IUserContext userContext)
{ {
_surveyService = surveyService; _surveyService = surveyService;
_userContext = userContext; _userContext = userContext;
} }
/// <summary>
/// Получить ВСЕ опросы
/// </summary>
/// <remarks>Возвращает массив вообще всех опросов</remarks>
/// <returns></returns>
[AllowAnonymous] [AllowAnonymous]
[HttpGet] [HttpGet]
[ProducesResponseType(typeof(List<OutputSurveyDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(List<OutputSurveyDto>), StatusCodes.Status200OK)]
@ -33,6 +40,12 @@ public class SurveyController : ControllerBase
return Ok(result); return Ok(result);
} }
/// <summary>
/// Получить опрос по ID
/// </summary>
/// <remarks>А что тут говорить то</remarks>
/// <param name="id"></param>
/// <returns></returns>
[AllowAnonymous] [AllowAnonymous]
[HttpGet("{id}")] [HttpGet("{id}")]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@ -44,6 +57,12 @@ public class SurveyController : ControllerBase
return Ok(result); return Ok(result);
} }
/// <summary>
/// Добавить новый опрос
/// </summary>
/// <remarks></remarks>
/// <param name="dto"></param>
/// <returns></returns>
[Authorize] [Authorize]
[HttpPost] [HttpPost]
[ProducesResponseType(StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status201Created)]
@ -56,6 +75,12 @@ public class SurveyController : ControllerBase
return Created(); return Created();
} }
/// <summary>
/// Удалить опрос по ID
/// </summary>
/// <remarks>Опрос должен быть создан тобой чтоб его удалить</remarks>
/// <param name="id"></param>
/// <returns></returns>
[Authorize] [Authorize]
[HttpDelete("{id}")] [HttpDelete("{id}")]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@ -67,6 +92,11 @@ public class SurveyController : ControllerBase
return Ok(); return Ok();
} }
/// <summary>
/// Получить МОИ опроса
/// </summary>
/// <remarks>Возвращает только опросы созданные нынешним юзером</remarks>
/// <returns></returns>
[Authorize] [Authorize]
[HttpGet("my")] [HttpGet("my")]
[ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status401Unauthorized)]

View file

@ -3,6 +3,9 @@ using SurveyLib.Core.Services;
namespace SurveyBackend.Controllers; namespace SurveyBackend.Controllers;
/// <summary>
/// Удалим когда-нибудь
/// </summary>
[ApiController] [ApiController]
[Route("api/test")] [Route("api/test")]
public class TestController : ControllerBase public class TestController : ControllerBase

View file

@ -1,9 +1,21 @@
namespace SurveyBackend.DTOs.Question; namespace SurveyBackend.DTOs.Question;
/// <summary>
/// Схема для создания нового Question
/// </summary>
public class CreateQuestionDto public class CreateQuestionDto
{ {
/// <summary>
/// Название вопроса
/// </summary>
public required string Title { get; set; } public required string Title { get; set; }
/// <summary>
/// Тип вопроса
/// </summary>
public required string QuestionType { get; set; } public required string QuestionType { get; set; }
/// <summary>
/// Варианты ответа (только если вопрос с выбором)
/// </summary>
public List<string>? AnswerVariants { get; set; } public List<string>? AnswerVariants { get; set; }
} }

View file

@ -1,8 +1,20 @@
namespace SurveyBackend.DTOs.Question; namespace SurveyBackend.DTOs.Question;
/// <summary>
/// Выходная схема вариантов ответа
/// </summary>
public class OutputAnswerVariantDto public class OutputAnswerVariantDto
{ {
/// <summary>
/// ID варианта ответа
/// </summary>
public required int Id { get; set; } public required int Id { get; set; }
/// <summary>
/// ID родительского вопроса
/// </summary>
public required int QuestionId { get; set; } public required int QuestionId { get; set; }
/// <summary>
/// Текст варианта ответа
/// </summary>
public required string Text { get; set; } public required string Text { get; set; }
} }

View file

@ -1,10 +1,32 @@
namespace SurveyBackend.DTOs.Question; namespace SurveyBackend.DTOs.Question;
/// <summary>
/// Выходнпя схема вопроса
/// </summary>
public class OutputQuestionDto public class OutputQuestionDto
{ {
/// <summary>
/// ID вопроса
/// </summary>
public required int Id { get; set; } public required int Id { get; set; }
/// <summary>
/// ID родительского опроса
/// </summary>
public required int SurveyId { get; set; } public required int SurveyId { get; set; }
/// <summary>
/// Заголовок вопроса
/// </summary>
public required string Title { get; set; } public required string Title { get; set; }
/// <summary>
/// Тип вопроса
/// </summary>
public required string QuestionType { get; set; } public required string QuestionType { get; set; }
/// <summary>
/// Варианты ответа
/// </summary>
public List<OutputAnswerVariantDto>? AnswerVariants { get; set; } public List<OutputAnswerVariantDto>? AnswerVariants { get; set; }
} }

View file

@ -1,7 +1,16 @@
namespace SurveyBackend.DTOs.Survey; namespace SurveyBackend.DTOs.Survey;
/// <summary>
/// Схема для создания нового опроса
/// </summary>
public class CreateSurveyDto public class CreateSurveyDto
{ {
/// <summary>
/// Название опроса
/// </summary>
public required string Title { get; set; } public required string Title { get; set; }
/// <summary>
/// Опциональное описание опроса
/// </summary>
public string Description { get; set; } = string.Empty; public string Description { get; set; } = string.Empty;
} }

View file

@ -1,9 +1,24 @@
namespace SurveyBackend.DTOs.Survey; namespace SurveyBackend.DTOs.Survey;
/// <summary>
/// Выходная схема опроса
/// </summary>
public class OutputSurveyDto public class OutputSurveyDto
{ {
/// <summary>
/// ID опроса
/// </summary>
public required int Id { get; set; } public required int Id { get; set; }
/// <summary>
/// Название опроса
/// </summary>
public required string Title { get; set; } public required string Title { get; set; }
/// <summary>
/// Описание опроса
/// </summary>
public required string Description { get; set; } public required string Description { get; set; }
/// <summary>
/// Создатель опроса (опционально)
/// </summary>
public int? CreatedBy { get; set; } public int? CreatedBy { get; set; }
} }

View file

@ -1,7 +1,16 @@
namespace SurveyBackend.DTOs; namespace SurveyBackend.DTOs;
/// <summary>
/// Схема авторизации пользователя
/// </summary>
public record UserLoginDto public record UserLoginDto
{ {
/// <summary>
/// Почта
/// </summary>
public required string Email { get; set; } public required string Email { get; set; }
/// <summary>
/// Пароль
/// </summary>
public required string Password { get; set; } public required string Password { get; set; }
} }

View file

@ -1,10 +1,32 @@
namespace SurveyBackend.DTOs; namespace SurveyBackend.DTOs;
/// <summary>
/// Схема регистрации пользователя
/// </summary>
public record UserRegistrationDto public record UserRegistrationDto
{ {
public string Email { get; set; } /// <summary>
/// Почта
/// </summary>
public required string Email { get; set; }
/// <summary>
/// Юзернейм
/// </summary>
public string Username { get; set; } public string Username { get; set; }
/// <summary>
/// Имя
/// </summary>
public string FirstName { get; set; } public string FirstName { get; set; }
/// <summary>
/// Фамилия
/// </summary>
public string LastName { get; set; } public string LastName { get; set; }
/// <summary>
/// Пароль
/// </summary>
public string Password { get; set; } public string Password { get; set; }
} }

View file

@ -0,0 +1,38 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace SurveyBackend.Filters;
/// <inheritdoc />
public class EndpointAuthRequirementFilter : IOperationFilter
{
/// <inheritdoc />
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
if (!context.ApiDescription
.ActionDescriptor
.EndpointMetadata
.OfType<AuthorizeAttribute>()
.Any())
{
return;
}
operation.Security = new List<OpenApiSecurityRequirement>
{
new OpenApiSecurityRequirement
{
[new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = JwtBearerDefaults.AuthenticationScheme
}
}] = new List<string>()
}
};
}
}

View file

@ -3,8 +3,16 @@ using SurveyBackend.DTOs;
namespace SurveyBackend.Mappers; namespace SurveyBackend.Mappers;
/// <summary>
/// Маппер всего связанного с авторизацией
/// </summary>
public static class AuthMapper public static class AuthMapper
{ {
/// <summary>
/// Перегнать схему регистрации в нового юзера
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
public static User UserRegistrationToModel(UserRegistrationDto dto) => new User public static User UserRegistrationToModel(UserRegistrationDto dto) => new User
{ {
Email = dto.Email, Email = dto.Email,

View file

@ -5,8 +5,18 @@ using SurveyLib.Core.Models.QuestionVariants;
namespace SurveyBackend.Mappers; namespace SurveyBackend.Mappers;
/// <summary>
/// Маппер всего про вопросы
/// </summary>
public static class QuestionMapper public static class QuestionMapper
{ {
/// <summary>
/// Создание вопроса в модель
/// </summary>
/// <param name="dto"></param>
/// <param name="surveyId"></param>
/// <returns></returns>
/// <exception cref="BadRequestException"></exception>
public static QuestionBase QuestionCreationToModel(CreateQuestionDto dto, int surveyId) public static QuestionBase QuestionCreationToModel(CreateQuestionDto dto, int surveyId)
{ {
return dto.QuestionType.ToLower() switch return dto.QuestionType.ToLower() switch
@ -32,6 +42,11 @@ public static class QuestionMapper
}; };
} }
/// <summary>
/// Модель в выходную схему
/// </summary>
/// <param name="question"></param>
/// <returns></returns>
public static OutputQuestionDto ModelToQuestionDto(QuestionBase question) public static OutputQuestionDto ModelToQuestionDto(QuestionBase question)
{ {
var withAnswerVariants = question.GetType() != typeof(TextQuestion); var withAnswerVariants = question.GetType() != typeof(TextQuestion);

View file

@ -3,8 +3,17 @@ using SurveyLib.Core.Models;
namespace SurveyBackend.Mappers; namespace SurveyBackend.Mappers;
/// <summary>
/// Маппер всего про опросы
/// </summary>
public static class SurveyMapper public static class SurveyMapper
{ {
/// <summary>
/// Схема создания в модель
/// </summary>
/// <param name="dto"></param>
/// <param name="userId"></param>
/// <returns></returns>
public static Survey CreateDtoToModel(CreateSurveyDto dto, int userId) public static Survey CreateDtoToModel(CreateSurveyDto dto, int userId)
{ {
return new Survey return new Survey
@ -15,6 +24,11 @@ public static class SurveyMapper
}; };
} }
/// <summary>
/// Модель в выходную схему
/// </summary>
/// <param name="survey"></param>
/// <returns></returns>
public static OutputSurveyDto ModelToOutputDto(Survey survey) public static OutputSurveyDto ModelToOutputDto(Survey survey)
{ {
return new OutputSurveyDto return new OutputSurveyDto

View file

@ -2,17 +2,29 @@ using SurveyBackend.Services.Exceptions;
namespace SurveyBackend.Middlewares; namespace SurveyBackend.Middlewares;
/// <summary>
/// Имбовая миддлваря, ловит все эксепшны, кастомные прокидывает как HTTP-exception, остальные кидает 502 и кайфуем
/// </summary>
public class ExceptionsMiddleware public class ExceptionsMiddleware
{ {
private readonly RequestDelegate _next; private readonly RequestDelegate _next;
private readonly ILogger<ExceptionsMiddleware> _logger; private readonly ILogger<ExceptionsMiddleware> _logger;
/// <summary>
/// Ну типа конструктор хз
/// </summary>
/// <param name="next"></param>
/// <param name="logger"></param>
public ExceptionsMiddleware(RequestDelegate next, ILogger<ExceptionsMiddleware> logger) public ExceptionsMiddleware(RequestDelegate next, ILogger<ExceptionsMiddleware> logger)
{ {
_next = next; _next = next;
_logger = logger; _logger = logger;
} }
/// <summary>
///
/// </summary>
/// <param name="context"></param>
public async Task InvokeAsync(HttpContext context) public async Task InvokeAsync(HttpContext context)
{ {
try try

View file

@ -7,6 +7,7 @@ using SurveyBackend.Contexts;
using SurveyBackend.Core.Contexts; using SurveyBackend.Core.Contexts;
using SurveyBackend.Core.Repositories; using SurveyBackend.Core.Repositories;
using SurveyBackend.Core.Services; using SurveyBackend.Core.Services;
using SurveyBackend.Filters;
using SurveyBackend.Infrastructure.Data; using SurveyBackend.Infrastructure.Data;
using SurveyBackend.Infrastructure.Repositories; using SurveyBackend.Infrastructure.Repositories;
using SurveyBackend.Middlewares; using SurveyBackend.Middlewares;
@ -84,20 +85,11 @@ public class Program
Name = "Authorization", Name = "Authorization",
Type = SecuritySchemeType.ApiKey Type = SecuritySchemeType.ApiKey
}); });
c.AddSecurityRequirement(new OpenApiSecurityRequirement
{ c.OperationFilter<EndpointAuthRequirementFilter>();
{
new OpenApiSecurityScheme var filePath = Path.Combine(System.AppContext.BaseDirectory, "SurveyBackend.API.xml");
{ c.IncludeXmlComments(filePath);
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = JwtBearerDefaults.AuthenticationScheme
}
},
Array.Empty<string>()
}
});
}); });
var app = builder.Build(); var app = builder.Build();

View file

@ -7,6 +7,14 @@
<RootNamespace>SurveyBackend</RootNamespace> <RootNamespace>SurveyBackend</RootNamespace>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
<DocumentationFile>bin\Debug\SurveyBackend.API.xml</DocumentationFile>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<DocumentationFile>bin\Release\SurveyBackend.API.xml</DocumentationFile>
</PropertyGroup>
<ItemGroup> <ItemGroup>
<Content Update="appsettings.*.json"> <Content Update="appsettings.*.json">
<CopyToPublishDirectory>Never</CopyToPublishDirectory> <CopyToPublishDirectory>Never</CopyToPublishDirectory>

View file

@ -26,7 +26,7 @@ public class SurveyService : ISurveyService
public async Task UpdateSurveyAsync(Survey survey) public async Task UpdateSurveyAsync(Survey survey)
{ {
if (survey.CreatedBy != _userContext.UserId) if (survey.CreatedBy != _userContext.UserId)
throw new UnauthorizedAccessException("You are not authorized to update this survey."); throw new UnauthorizedException("You are not authorized to update this survey.");
await _surveyRepository.UpdateAsync(survey); await _surveyRepository.UpdateAsync(survey);
} }
@ -40,7 +40,7 @@ public class SurveyService : ISurveyService
if (survey.CreatedBy != _userContext.UserId) if (survey.CreatedBy != _userContext.UserId)
{ {
throw new UnauthorizedAccessException("You are not authorized to delete this survey."); throw new UnauthorizedException("You are not authorized to delete this survey.");
} }
await _surveyRepository.DeleteAsync(survey); await _surveyRepository.DeleteAsync(survey);