-
Notifications
You must be signed in to change notification settings - Fork 0
Add admin controllers for cities, polls and ideas #62
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feature/polls-cqrs-handlers-ideas
Are you sure you want to change the base?
Changes from all commits
1b6a902
45d05b9
a4d85d6
1f018b1
679b4f8
7c7f1d5
e2f17e5
a7389bb
3ba077e
3c2682e
43813c0
ee1dea7
3793fc7
738ed89
c7c7c84
e820f85
8e20771
d1e1a40
35a6432
b311bfd
a9ad5f2
ad2405e
722b068
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| namespace Polls.API.Authorization; | ||
|
|
||
| public class Auth0Settings | ||
| { | ||
| public required string Domain { get; set; } | ||
| public required string Authority { get; set; } | ||
| public required string Audience { get; set; } | ||
| public required string ClientId { get; set; } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| using Microsoft.AspNetCore.Authentication.JwtBearer; | ||
| using Microsoft.Extensions.Options; | ||
|
|
||
| namespace Polls.API.Authorization; | ||
|
|
||
| public class ConfigureJwtBearerOptions( | ||
| IOptions<Auth0Settings> auth0Settings) : IConfigureOptions<JwtBearerOptions> | ||
| { | ||
| private readonly Auth0Settings _auth0Settings = auth0Settings.Value; | ||
|
|
||
| public void Configure(JwtBearerOptions options) | ||
| { | ||
| options.Authority = _auth0Settings.Authority; | ||
| options.Audience = _auth0Settings.Audience; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| using Microsoft.AspNetCore.Authorization; | ||
| using Polls.Domain.Authorization; | ||
|
|
||
| namespace Polls.API.Authorization; | ||
|
|
||
| public class PermissionPolicyProvider : IAuthorizationPolicyProvider | ||
| { | ||
| public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName) | ||
| { | ||
| var policy = new AuthorizationPolicyBuilder() | ||
| .RequireAuthenticatedUser() | ||
| .RequireClaim(Permissions.ClaimType, policyName) | ||
| .Build(); | ||
|
|
||
| return Task.FromResult<AuthorizationPolicy?>(policy); | ||
| } | ||
|
|
||
| public Task<AuthorizationPolicy> GetDefaultPolicyAsync() | ||
| { | ||
| return Task.FromResult( | ||
| new AuthorizationPolicyBuilder() | ||
| .RequireAuthenticatedUser() | ||
| .Build()); | ||
| } | ||
|
|
||
| public Task<AuthorizationPolicy?> GetFallbackPolicyAsync() | ||
| { | ||
| return Task.FromResult<AuthorizationPolicy?>(null); | ||
| } | ||
|
SubochArtem marked this conversation as resolved.
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| using System.Security.Claims; | ||
|
|
||
| namespace Polls.API.Common.Extensions; | ||
|
|
||
| public static class ClaimsPrincipalExtensions | ||
| { | ||
| private const string CityIdClaim = "https://citypulse.com/city_id"; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why not store it in appsettings?
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I preferred a constant over appsettings in this situation because:
|
||
|
|
||
| public static Guid GetUserId(this ClaimsPrincipal user) | ||
| { | ||
| var claim = user.FindFirstValue(ClaimTypes.NameIdentifier); | ||
| return Guid.TryParse(claim, out var id) | ||
| ? id | ||
| : Guid.Empty; | ||
| } | ||
|
|
||
| public static Guid GetCityId(this ClaimsPrincipal user) | ||
| { | ||
| var claim = user.FindFirstValue(CityIdClaim); | ||
| return Guid.TryParse(claim, out var id) | ||
| ? id | ||
| : Guid.Empty; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| using Microsoft.AspNetCore.Mvc; | ||
| using Microsoft.AspNetCore.Mvc.Filters; | ||
| using Polls.Domain.Common; | ||
| using Polls.Domain.Common.Enums; | ||
|
|
||
| namespace Polls.API.Common.Filters; | ||
|
|
||
| public class ResultFilter : IActionFilter | ||
| { | ||
| private const string ErrorsExtensionKey = "errors"; | ||
|
|
||
| public void OnActionExecuting(ActionExecutingContext context) { } | ||
|
|
||
| public void OnActionExecuted(ActionExecutedContext context) | ||
| { | ||
| if (context.Result is not ObjectResult { Value: Result { IsSuccess: false } result }) | ||
| return; | ||
|
|
||
| var statusCode = GetStatusCode(result.Error.Type); | ||
|
|
||
| var problemDetails = new ProblemDetails | ||
| { | ||
| Status = statusCode, | ||
| Title = result.Error.Type.ToString(), | ||
| Detail = result.Error.Description, | ||
| Instance = context.HttpContext.Request.Path | ||
| }; | ||
|
|
||
| if (result.Errors.Count > 1) | ||
| problemDetails.Extensions[ErrorsExtensionKey] = result.Errors | ||
| .Select(e => e.Description) | ||
| .ToArray(); | ||
|
|
||
| context.Result = new ObjectResult(problemDetails) | ||
| { | ||
| StatusCode = statusCode | ||
| }; | ||
| } | ||
|
|
||
| private static int GetStatusCode(ErrorType type) => type switch | ||
| { | ||
| ErrorType.NotFound => StatusCodes.Status404NotFound, | ||
| ErrorType.Conflict => StatusCodes.Status409Conflict, | ||
| ErrorType.Forbidden => StatusCodes.Status403Forbidden, | ||
| ErrorType.Validation => StatusCodes.Status400BadRequest, | ||
|
|
||
| _=> StatusCodes.Status400BadRequest | ||
|
SubochArtem marked this conversation as resolved.
|
||
| }; | ||
| } | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why using both result pattern and ExceptionMiddleware?
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I use |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| using Microsoft.AspNetCore.Mvc; | ||
|
|
||
| namespace Polls.API.Common.Middleware; | ||
|
|
||
| public class ExceptionHandlerMiddleware( | ||
| RequestDelegate next, | ||
| ILogger<ExceptionHandlerMiddleware> logger) | ||
| { | ||
| private const string ContentType = "application/json"; | ||
| private const string InternalServerErrorTitle = "Internal Server Error"; | ||
| private const string InternalServerErrorDetail = "An unexpected error occurred."; | ||
|
|
||
| public async Task InvokeAsync(HttpContext context) | ||
| { | ||
| try | ||
| { | ||
| await next(context); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| logger.LogError( | ||
| ex, | ||
| "Unhandled exception at {Method} {Path}", | ||
| context.Request.Method, | ||
| context.Request.Path); | ||
|
|
||
| await HandleExceptionAsync(context); | ||
| } | ||
| } | ||
|
|
||
| private static async Task HandleExceptionAsync(HttpContext context) | ||
| { | ||
| context.Response.StatusCode = StatusCodes.Status500InternalServerError; | ||
| context.Response.ContentType = ContentType; | ||
|
|
||
| var problemDetails = new ProblemDetails | ||
| { | ||
| Status = StatusCodes.Status500InternalServerError, | ||
| Title = InternalServerErrorTitle, | ||
| Detail = InternalServerErrorDetail, | ||
| Instance = context.Request.Path | ||
| }; | ||
|
|
||
| await context.Response.WriteAsJsonAsync(problemDetails); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| using MediatR; | ||
| using Microsoft.AspNetCore.Authorization; | ||
| using Microsoft.AspNetCore.Mvc; | ||
| using Polls.API.Requests.Cities; | ||
| using Polls.Application.Cities.Commands.ChangeStatus; | ||
| using Polls.Application.Cities.Commands.CreateCity; | ||
| using Polls.Application.Cities.Commands.DeleteCity; | ||
| using Polls.Application.Cities.Commands.UpdateCity; | ||
| using Polls.Application.Cities.DTOs; | ||
| using Polls.Application.Cities.Queries.GetCities; | ||
| using Polls.Application.Cities.Queries.GetCityById; | ||
| using Polls.Application.Cities.Queries.GetCityWithPolls; | ||
| using Polls.Application.Common.Models; | ||
| using Polls.Domain.Authorization; | ||
| using Polls.Domain.Common; | ||
|
|
||
| namespace Polls.API.Controllers.Admin; | ||
|
|
||
| [ApiController] | ||
| [Route("api/v1/admin/cities")] | ||
| [Authorize] | ||
| public class AdminCitiesController(ISender sender) : ControllerBase | ||
| { | ||
| [HttpGet] | ||
| [Authorize(Policy = Permissions.Cities.ReadAny)] | ||
| public async Task<Result<PagedList<CityDto>>> GetCities( | ||
| [FromQuery] CityFilter filter, | ||
| CancellationToken cancellationToken) | ||
| { | ||
| var query = new GetCitiesQuery( | ||
| Filter: filter, | ||
| IncludeOnlyActive: false); | ||
|
|
||
| return await sender.Send(query, cancellationToken); | ||
| } | ||
|
|
||
| [HttpGet("{id:guid}")] | ||
| [Authorize(Policy = Permissions.Cities.ReadAny)] | ||
| public async Task<Result<CityDto>> GetCityById( | ||
| Guid id, | ||
| CancellationToken cancellationToken) | ||
| { | ||
| var query = new GetCityByIdQuery( | ||
| Id: id, | ||
| IncludeOnlyActive: false); | ||
|
|
||
| return await sender.Send(query, cancellationToken); | ||
| } | ||
|
|
||
| [HttpGet("{id:guid}/polls")] | ||
| [Authorize(Policy = Permissions.Cities.ReadAny)] | ||
| public async Task<Result<CityWithPollsDto>> GetCityWithPolls( | ||
| Guid id, | ||
| CancellationToken cancellationToken) | ||
| { | ||
| var query = new GetCityWithPollsQuery( | ||
| Id: id, | ||
| IncludeOnlyActive: false); | ||
|
|
||
| return await sender.Send(query, cancellationToken); | ||
| } | ||
|
|
||
| [HttpPost] | ||
| [Authorize(Policy = Permissions.Cities.CreateAny)] | ||
| public async Task<Result<CityDto>> CreateCity( | ||
| CreateCityRequest request, | ||
| CancellationToken cancellationToken) | ||
| { | ||
| var command = new CreateCityCommand( | ||
| Title: request.Title, | ||
| Coordinates: request.Coordinates, | ||
| Description: request.Description); | ||
|
|
||
| return await sender.Send(command, cancellationToken); | ||
| } | ||
|
|
||
| [HttpPut("{id:guid}")] | ||
| [Authorize(Policy = Permissions.Cities.UpdateAny)] | ||
| public async Task<Result<CityDto>> UpdateCity( | ||
| Guid id, | ||
| UpdateCityRequest request, | ||
| CancellationToken cancellationToken) | ||
| { | ||
| var command = new UpdateCityCommand( | ||
| Id: id, | ||
| Title: request.Title, | ||
| Coordinates: request.Coordinates, | ||
| Description: request.Description); | ||
|
|
||
| return await sender.Send(command, cancellationToken); | ||
| } | ||
|
|
||
| [HttpDelete("{id:guid}")] | ||
| [Authorize(Policy = Permissions.Cities.DeleteAny)] | ||
| public async Task<Result<Unit>> DeleteCity( | ||
| Guid id, | ||
| CancellationToken cancellationToken) | ||
| { | ||
| var command = new DeleteCityCommand( | ||
| Id: id); | ||
|
|
||
| return await sender.Send(command, cancellationToken); | ||
| } | ||
|
|
||
| [HttpPatch("{id:guid}/status")] | ||
| [Authorize(Policy = Permissions.Cities.ChangeStatusAny)] | ||
| public async Task<Result<Unit>> ChangeStatus( | ||
| Guid id, | ||
| ChangeCityStatusRequest request, | ||
| CancellationToken cancellationToken) | ||
| { | ||
| var command = new ChangeCityStatusCommand( | ||
| Id: id, | ||
| NewStatus: request.NewStatus); | ||
|
|
||
| return await sender.Send(command, cancellationToken); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There is no
asyncwork .Usingasyncwithoutawaitwould create astate machineunnecessarily.Task.FromResultreturns an already-completed task with zero overhead