Compare commits

...

13 commits

20 changed files with 573 additions and 143 deletions

View file

@ -1,6 +1,7 @@
# Introduction
![Build Status](https://circleci.com/gh/dnutiu/retroactiune.svg?style=svg)
![Build Status](https://circleci.com/gh/dnutiu/retroactiune.svg?style=svg)
![GitHub commit activity](https://img.shields.io/github/commit-activity/m/dnutiu/retroactiune) ![GitHub repo size](https://img.shields.io/github/repo-size/dnutiu/retroactiune) ![GitHub top language](https://img.shields.io/github/languages/top/dnutiu/retroactiune)
![Swagger API](./docs/retroactiune_swagger.png)
@ -12,7 +13,7 @@ The given Feedback is anonymous by design.
## Tech Stack
The project uses ASP .Net Core 3.1 and [MongoDB](https://www.mongodb.com/).
The project uses [ASP .Net Core 3.1](https://docs.microsoft.com/en-us/aspnet/core/) and [MongoDB](https://www.mongodb.com/).
```bash
dotnet --version
@ -30,8 +31,18 @@ The application code is organized using the [Clean Architecture](https://docs.mi
![Example deployment architecture](./docs/app_architecture_layers.png)
## Developing
## Authorization Provider
An external Authorization provider is required in order to run the API, the provider needs to support
Bearer tokens and HS256 key signing algorithm. _RS256 is currently not supported._
I recommend that you start with [Auth0](https://auth0.com/) and their free tier.
See the following resources:
- https://auth0.com/docs/get-started/set-up-apis
- https://developers.redhat.com/blog/2020/01/29/api-login-and-jwt-token-generation-using-keycloak#set_up_a_client
## Developing
To install the dependencies run `dotnet restore`.
@ -48,4 +59,6 @@ _Note: [Docker](https://www.docker.com/) and [Docker-Compose](https://docs.docke
```bash
docker-compose up -d
dotnet test
```
```
The projects has ~96% code coverage.

View file

@ -1,4 +1,5 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
@ -10,13 +11,12 @@ namespace Retroactiune.Core.Entities
/// </summary>
public class Feedback
{
public Feedback()
{
Id = ObjectId.GenerateNewId().ToString();
CreatedAt = DateTime.UtcNow;
}
[BsonId, JsonPropertyName("id")]
[BsonRepresentation(BsonType.ObjectId)]
public string Id { get; set; }
@ -24,12 +24,31 @@ namespace Retroactiune.Core.Entities
[JsonPropertyName("feedback_receiver_id")]
[BsonRepresentation(BsonType.ObjectId)]
public string FeedbackReceiverId { get; set; }
[JsonPropertyName("rating")]
public uint Rating { get; set; }
[JsonPropertyName("rating")] public uint Rating { get; set; }
[JsonPropertyName("description")] public string Description { get; set; }
[JsonPropertyName("created_at")] public DateTime CreatedAt { get; set; }
private bool Equals(Feedback other)
{
return Id == other.Id && FeedbackReceiverId == other.FeedbackReceiverId && Rating == other.Rating &&
Description == other.Description && CreatedAt - other.CreatedAt < TimeSpan.FromSeconds(1);
}
public override bool Equals(object obj)
{
if (ReferenceEquals(null, obj)) return false;
if (ReferenceEquals(this, obj)) return true;
if (obj.GetType() != this.GetType()) return false;
return Equals((Feedback) obj);
}
[SuppressMessage("ReSharper", "NonReadonlyMemberInGetHashCode")]
public override int GetHashCode()
{
return HashCode.Combine(Id, FeedbackReceiverId, Rating, Description, CreatedAt);
}
}
}

View file

@ -33,6 +33,16 @@ namespace Retroactiune.Core.Entities
[JsonPropertyName("expiry_time")] public DateTime? ExpiryTime { get; set; }
public static bool operator ==(Token left, Token right)
{
return Equals(left, right);
}
public static bool operator !=(Token left, Token right)
{
return !Equals(left, right);
}
public override bool Equals(object obj)
{
if (!(obj is Token convertedObj))
@ -50,13 +60,18 @@ namespace Retroactiune.Core.Entities
return RuntimeHelpers.GetHashCode(this);
}
public bool IsValid()
{
var hasExpired = ExpiryTime != null && ExpiryTime <= DateTime.UtcNow;
var isUsed = TimeUsed != null;
return !(hasExpired || isUsed);
}
public bool IsValid(FeedbackReceiver feedbackReceiver)
{
Guard.Against.Null(feedbackReceiver, nameof(feedbackReceiver));
var hasExpired = ExpiryTime != null && ExpiryTime <= DateTime.UtcNow;
var differentFeedbackReceiver = !FeedbackReceiverId.Equals(feedbackReceiver.Id);
var isUsed = TimeUsed != null;
return !(hasExpired || differentFeedbackReceiver || isUsed);
return !differentFeedbackReceiver && IsValid();
}
}
}

View file

@ -1,4 +1,5 @@
using System;
using System.Diagnostics.CodeAnalysis;
namespace Retroactiune.Core.Services
{
@ -7,12 +8,11 @@ namespace Retroactiune.Core.Services
/// </summary>
public class FeedbacksListFilters
{
/// <summary>
/// FeedbackReceiverId the ID of the FeedbackReceiver.
/// </summary>
public string FeedbackReceiverId { get; set; }
/// <summary>
/// CreatedAfter filters items that have been created after the given date.
/// </summary>
@ -27,5 +27,23 @@ namespace Retroactiune.Core.Services
/// Rating filters for the rating.
/// </summary>
public uint Rating { get; set; }
public override bool Equals(object obj)
{
return obj != null && Equals((FeedbacksListFilters) obj);
}
private bool Equals(FeedbacksListFilters other)
{
return FeedbackReceiverId == other.FeedbackReceiverId &&
Nullable.Equals(CreatedAfter, other.CreatedAfter) &&
Nullable.Equals(CreatedBefore, other.CreatedBefore) && Rating == other.Rating;
}
[SuppressMessage("ReSharper", "NonReadonlyMemberInGetHashCode")]
public override int GetHashCode()
{
return HashCode.Combine(FeedbackReceiverId, CreatedAfter, CreatedBefore, Rating);
}
}
}

View file

@ -28,7 +28,6 @@ namespace Retroactiune.Core.Services
public async Task<IEnumerable<Feedback>> GetFeedbacksAsync(FeedbacksListFilters filters)
{
// TODO: Unit test.
Guard.Against.Null(filters, nameof(filters));
Guard.Against.Null(filters.FeedbackReceiverId, nameof(filters.FeedbackReceiverId));

View file

@ -11,7 +11,7 @@
<PackageReference Include="AutoFixture.Xunit2" Version="4.17.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="3.1.15" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.5.0" />
<PackageReference Include="Newtonsoft.Json" Version="9.0.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="xunit" Version="2.4.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.0" />
<PackageReference Include="coverlet.collector" Version="1.2.0" />

View file

@ -435,15 +435,42 @@ namespace Retroactiune.IntegrationTests.Retroactiune.WebAPI.Controllers
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var feedbacksCursor = await _mongoDb.FeedbacksCollection.FindAsync(FilterDefinition<Feedback>.Empty);
var feedbacks = await feedbacksCursor.ToListAsync();
Assert.Equal("ok", feedbacks.ElementAt(0).Description);
Assert.Equal(4u, feedbacks.ElementAt(0).Rating);
Assert.Equal(feedbackReceiver.Id, feedbacks.ElementAt(0).FeedbackReceiverId);
var tokensCursor = await _mongoDb.TokensCollection.FindAsync(FilterDefinition<Token>.Empty);
var tokens = await tokensCursor.ToListAsync();
Assert.NotNull(tokens.ElementAt(0).TimeUsed);
}
[Theory, AutoData]
public async Task Test_GetFeedbacks(IEnumerable<Feedback> feedbacksSeed)
{
// Setup
await _mongoDb.DropAsync();
var feedbackReceiverGuid = ObjectId.GenerateNewId().ToString();
var selectedFeedbacksSeed = feedbacksSeed.Select(i =>
{
i.Id = ObjectId.GenerateNewId().ToString();
i.CreatedAt = i.CreatedAt.ToUniversalTime();
i.FeedbackReceiverId = feedbackReceiverGuid;
return i;
}).ToList();
await _mongoDb.FeedbacksCollection.InsertManyAsync(selectedFeedbacksSeed);
// Test
var response = await _client.GetAsync($"/api/v1/feedback_receivers/{feedbackReceiverGuid}/feedbacks");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var feedbacksResponse =
JsonSerializer.Deserialize<List<Feedback>>(await response.Content.ReadAsStringAsync());
Assert.Equal(selectedFeedbacksSeed,feedbacksResponse);
}
}
}

View file

@ -81,5 +81,110 @@ namespace Retroactiune.Tests.Retroactiune.Core.Services
It.IsAny<InsertOneOptions>(),
It.IsAny<CancellationToken>()));
}
[Fact]
public async Task Test_GetFeedbacksAsync_NullGuards()
{
// Setup
var mongoDatabaseMock = new Mock<IMongoDatabase>();
var mongoClientMock = new Mock<IMongoClient>();
var mongoSettingsMock = new Mock<IDatabaseSettings>();
var mongoCollectionMock = new Mock<IMongoCollection<Feedback>>();
mongoSettingsMock.SetupGet(i => i.DatabaseName).Returns("MyDB");
mongoSettingsMock.SetupGet(i => i.FeedbacksCollectionName).Returns("feedbacks");
mongoClientMock
.Setup(stub => stub.GetDatabase(It.IsAny<string>(),
It.IsAny<MongoDatabaseSettings>()))
.Returns(mongoDatabaseMock.Object);
mongoDatabaseMock
.Setup(i => i.GetCollection<Feedback>(It.IsAny<string>(),
It.IsAny<MongoCollectionSettings>()))
.Returns(mongoCollectionMock.Object);
// Test & Assert
var service = new FeedbacksService(mongoClientMock.Object, mongoSettingsMock.Object);
await Assert.ThrowsAsync<ArgumentNullException>(async () => { await service.GetFeedbacksAsync(null); });
await Assert.ThrowsAsync<ArgumentNullException>(async () =>
{
await service.GetFeedbacksAsync(new FeedbacksListFilters());
});
}
[Theory, AutoData]
public async Task Test_GetFeedbacksAsync_Happy(FeedbacksListFilters feedbacksListFilters)
{
// Setup
var mongoDatabaseMock = new Mock<IMongoDatabase>();
var mongoClientMock = new Mock<IMongoClient>();
var mongoSettingsMock = new Mock<IDatabaseSettings>();
var mongoCollectionMock = new Mock<IMongoCollection<Feedback>>();
mongoSettingsMock.SetupGet(i => i.DatabaseName).Returns("MyDB");
mongoSettingsMock.SetupGet(i => i.FeedbacksCollectionName).Returns("feedbacks");
mongoClientMock
.Setup(stub => stub.GetDatabase(It.IsAny<string>(),
It.IsAny<MongoDatabaseSettings>()))
.Returns(mongoDatabaseMock.Object);
mongoDatabaseMock
.Setup(i => i.GetCollection<Feedback>(It.IsAny<string>(),
It.IsAny<MongoCollectionSettings>()))
.Returns(mongoCollectionMock.Object);
mongoCollectionMock.Setup(i => i.FindAsync(It.IsAny<FilterDefinition<Feedback>>(),
It.IsAny<FindOptions<Feedback, Feedback>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new Mock<IAsyncCursor<Feedback>>().Object);
// Test
var service = new FeedbacksService(mongoClientMock.Object, mongoSettingsMock.Object);
await service.GetFeedbacksAsync(feedbacksListFilters);
// Assert
mongoCollectionMock.Verify(i => i.FindAsync(It.IsAny<FilterDefinition<Feedback>>(),
It.IsAny<FindOptions<Feedback, Feedback>>(), It.IsAny<CancellationToken>()));
}
[Theory, AutoData]
public async Task Test_GetFeedbacksAsync_Happy_MinimalFilters(string feedbackReceiverId)
{
// Setup
var mongoDatabaseMock = new Mock<IMongoDatabase>();
var mongoClientMock = new Mock<IMongoClient>();
var mongoSettingsMock = new Mock<IDatabaseSettings>();
var mongoCollectionMock = new Mock<IMongoCollection<Feedback>>();
mongoSettingsMock.SetupGet(i => i.DatabaseName).Returns("MyDB");
mongoSettingsMock.SetupGet(i => i.FeedbacksCollectionName).Returns("feedbacks");
mongoClientMock
.Setup(stub => stub.GetDatabase(It.IsAny<string>(),
It.IsAny<MongoDatabaseSettings>()))
.Returns(mongoDatabaseMock.Object);
mongoDatabaseMock
.Setup(i => i.GetCollection<Feedback>(It.IsAny<string>(),
It.IsAny<MongoCollectionSettings>()))
.Returns(mongoCollectionMock.Object);
mongoCollectionMock.Setup(i => i.FindAsync(It.IsAny<FilterDefinition<Feedback>>(),
It.IsAny<FindOptions<Feedback, Feedback>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new Mock<IAsyncCursor<Feedback>>().Object);
// Test
var service = new FeedbacksService(mongoClientMock.Object, mongoSettingsMock.Object);
await service.GetFeedbacksAsync(new FeedbacksListFilters()
{
FeedbackReceiverId = feedbackReceiverId
});
// Assert
mongoCollectionMock.Verify(i => i.FindAsync(It.IsAny<FilterDefinition<Feedback>>(),
It.IsAny<FindOptions<Feedback, Feedback>>(), It.IsAny<CancellationToken>()));
}
}
}

View file

@ -202,9 +202,6 @@ namespace Retroactiune.Tests.Retroactiune.WebAPI.Controllers
mockService.Verify(s => s.FindAsync(filterArr, offset, limit), Times.Once);
}
// Invalid token
// happy
[Theory, AutoData]
public async Task AddFeedback_No_FeedbackReceiver(FeedbackInDto requestBody)
{
@ -272,7 +269,7 @@ namespace Retroactiune.Tests.Retroactiune.WebAPI.Controllers
TimeUsed = DateTime.UtcNow
}
});
// Test
var controller = new FeedbackReceiversController(feedbackReceiversService.Object, tokensService.Object,
feedbacksService.Object, mapper, null,
@ -282,8 +279,8 @@ namespace Retroactiune.Tests.Retroactiune.WebAPI.Controllers
// Assert
Assert.IsType<BadRequestObjectResult>(result);
}
[Theory, AutoData]
public async Task AddFeedback_Happy(FeedbackInDto requestBody)
{
@ -295,11 +292,15 @@ namespace Retroactiune.Tests.Retroactiune.WebAPI.Controllers
var logger = new Mock<ILogger<FeedbackReceiversController>>();
feedbackReceiversService
.Setup(i => i.FindAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<int?>(), It.IsAny<int?>()))
.ReturnsAsync(new[] {new FeedbackReceiver
.Setup(i => i.FindAsync(It.IsAny<IEnumerable<string>>(),
It.IsAny<int?>(), It.IsAny<int?>()))
.ReturnsAsync(new[]
{
Id = "batman"
}});
new FeedbackReceiver
{
Id = "batman"
}
});
tokensService.Setup(i => i.FindAsync(It.IsAny<TokenListFilters>()))
.ReturnsAsync(new[]
@ -310,7 +311,7 @@ namespace Retroactiune.Tests.Retroactiune.WebAPI.Controllers
TimeUsed = null
}
});
// Test
var controller = new FeedbackReceiversController(feedbackReceiversService.Object, tokensService.Object,
feedbacksService.Object, mapper, null, logger.Object);
@ -318,6 +319,32 @@ namespace Retroactiune.Tests.Retroactiune.WebAPI.Controllers
// Assert
Assert.IsType<OkResult>(result);
feedbacksService.Verify(i => i.AddFeedbackAsync(It.IsAny<Feedback>(),
It.IsAny<FeedbackReceiver>()));
tokensService.Verify(i => i.MarkTokenAsUsedAsync(It.IsAny<Token>()));
}
[Theory, AutoData]
public async Task GetFeedbacks_Happy(string guid, ListFeedbacksFiltersDto filters)
{
// Arrange
var mapper = TestUtils.GetMapper();
var feedbackReceiversService = new Mock<IFeedbackReceiversService>();
var tokensService = new Mock<ITokensService>();
var feedbacksService = new Mock<IFeedbacksService>();
var logger = new Mock<ILogger<FeedbackReceiversController>>();
// Test
var controller = new FeedbackReceiversController(feedbackReceiversService.Object, tokensService.Object,
feedbacksService.Object, mapper, null, logger.Object);
var result = await controller.GetFeedbacks(guid, filters);
// Assert
Assert.IsType<OkObjectResult>(result);
var listFilters = mapper.Map<FeedbacksListFilters>(filters);
listFilters.FeedbackReceiverId = guid;
feedbacksService.Verify(i => i.GetFeedbacksAsync(listFilters));
}
}
}

View file

@ -172,5 +172,47 @@ namespace Retroactiune.Tests.Retroactiune.WebAPI.Controllers
Assert.IsType<BadRequestObjectResult>(result);
tokens.Verify(i => i.FindAsync(It.IsAny<TokenListFilters>()), Times.Once);
}
[Fact]
public async Task Test_CheckToken_NotFound()
{
// Arrange
var mapper = TestUtils.GetMapper();
var feedbackService = new Mock<IFeedbackReceiversService>();
var tokens = new Mock<ITokensService>();
var logger = new Mock<ILogger<TokensController>>();
tokens.Setup(i => i.FindAsync(It.IsAny<TokenListFilters>()))
.ReturnsAsync(new List<Token>());
// Test
var controller = new TokensController(feedbackService.Object, tokens.Object, logger.Object, mapper);
var result = await controller.CheckToken("random");
// Assert
var checkResult = (CheckTokenDto) ((ObjectResult) result).Value;
Assert.IsType<OkObjectResult>(result);
Assert.False(checkResult.IsValid);
}
[Fact]
public async Task Test_CheckToken_Valid()
{
// Arrange
var mapper = TestUtils.GetMapper();
var feedbackService = new Mock<IFeedbackReceiversService>();
var tokens = new Mock<ITokensService>();
var logger = new Mock<ILogger<TokensController>>();
tokens.Setup(i => i.FindAsync(It.IsAny<TokenListFilters>()))
.ReturnsAsync(new List<Token> { new Token() });
// Test
var controller = new TokensController(feedbackService.Object, tokens.Object, logger.Object, mapper);
var result = await controller.CheckToken("random");
// Assert
var checkResult = (CheckTokenDto) ((ObjectResult) result).Value;
Assert.IsType<OkObjectResult>(result);
Assert.True(checkResult.IsValid);
}
}
}

View file

@ -0,0 +1,94 @@
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Retroactiune.Core.Entities;
using Retroactiune.Core.Services;
using Retroactiune.DataTransferObjects;
namespace Retroactiune.Controllers
{
/// <summary>
/// Implementation FeedbackReceiversController and Feedbacks related functionality.
/// </summary>
public partial class FeedbackReceiversController
{
/// <summary>
/// Add Feedback to a FeedbackReceiver.
/// </summary>
/// <param name="feedbackInDto">The feedback dto.</param>
/// <response code="200">The feedback has been added.</response>
/// <response code="400">The request is invalid.</response>
/// <returns></returns>
[HttpPost("feedbacks")]
[ProducesResponseType(typeof(NoContentResult), StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(BasicResponse), StatusCodes.Status400BadRequest)]
public async Task<IActionResult> AddFeedback([FromBody] FeedbackInDto feedbackInDto)
{
var tokenEnum = await _tokensService.FindAsync(new TokenListFilters
{
Ids = new[] {feedbackInDto.TokenId}
});
var tokens = (tokenEnum as Token[] ?? tokenEnum.ToArray());
if (tokens.Length == 0)
{
return BadRequest(new BasicResponse
{
Message = "Token not found."
});
}
var token = tokens[0];
var receivers = await _feedbackReceiversService.FindAsync(
new[] {token.FeedbackReceiverId}, limit: 1
);
var feedbackReceivers = receivers as FeedbackReceiver[] ?? receivers.ToArray();
if (!feedbackReceivers.Any())
{
return BadRequest(new BasicResponse
{
Message = $"FeedbackReceiver with id {token.FeedbackReceiverId} not found."
});
}
if (!token.IsValid(feedbackReceivers[0]))
{
return BadRequest(new BasicResponse
{
Message = "Token is invalid."
});
}
var feedback = _mapper.Map<Feedback>(feedbackInDto);
await Task.WhenAll(_tokensService.MarkTokenAsUsedAsync(token),
_feedbacksService.AddFeedbackAsync(feedback, feedbackReceivers[0]));
return Ok();
}
/// <summary>
/// Returns the Feedbacks of a FeedbackReceiver. See <see cref="Feedback"/> and <see cref="FeedbackReceiver"/>.
/// </summary>
/// <param name="guid">The guid of the FeedbackReceiver.</param>
/// <param name="filters">Query filters for filtering the response.</param>
/// <response code="200">The feedback has been added.</response>
/// <response code="400">The request is invalid.</response>
/// <returns></returns>
[Authorize]
[HttpGet("{guid}/feedbacks")]
[ProducesResponseType(typeof(NoContentResult), StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(BasicResponse), StatusCodes.Status400BadRequest)]
[ProducesResponseType( StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> GetFeedbacks(string guid, [FromQuery] ListFeedbacksFiltersDto filters)
{
var feedbacksListFilters = _mapper.Map<FeedbacksListFilters>(filters);
feedbacksListFilters.FeedbackReceiverId = guid;
var response = await _feedbacksService.GetFeedbacksAsync(feedbacksListFilters);
return Ok(response);
}
}
}

View file

@ -3,6 +3,7 @@ using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
@ -16,7 +17,7 @@ namespace Retroactiune.Controllers
{
[ApiController]
[Route("api/v1/feedback_receivers")]
public class FeedbackReceiversController : ControllerBase
public partial class FeedbackReceiversController : ControllerBase
{
private readonly IOptions<ApiBehaviorOptions> _apiBehaviorOptions;
@ -46,10 +47,12 @@ namespace Retroactiune.Controllers
/// <param name="items">The list of FeedbackReceivers</param>
/// <returns>A BasicResponse indicating success.</returns>
/// <response code="200">Returns an ok message.</response>
/// <response code="400">If the items is invalid</response>
/// <response code="400">If the items is invalid</response>
[Authorize]
[HttpPost]
[ProducesResponseType(typeof(BasicResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType( StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> Post([Required] IEnumerable<FeedbackReceiverInDto> items)
{
var feedbackReceiversDto = items.ToList();
@ -77,9 +80,11 @@ namespace Retroactiune.Controllers
/// <returns>A NoContent result.</returns>
/// <response code="204">The delete is submitted.</response>
/// <response code="400">The request is invalid.</response>
[Authorize]
[HttpDelete("{guid}")]
[ProducesResponseType(typeof(NoContentResult), StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType( StatusCodes.Status401Unauthorized)]
public async Task<NoContentResult> Delete(
[StringLength(24, ErrorMessage = "invalid guid, must be 24 characters", MinimumLength = 24)]
string guid)
@ -96,11 +101,13 @@ namespace Retroactiune.Controllers
/// <returns>A Ok result with a <see cref="FeedbackReceiverOutDto"/>.</returns>
/// <response code="200">The item returned successfully.</response>
/// <response code="400">The request is invalid.</response>
/// <response code="404">The item was not found.</response>
/// <response code="404">The item was not found.</response>
[Authorize]
[HttpGet("{guid}")]
[ProducesResponseType(typeof(FeedbackReceiverOutDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(BasicResponse), StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType( StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> Get(
[StringLength(24, ErrorMessage = "invalid guid, must be 24 characters", MinimumLength = 24)]
string guid)
@ -126,10 +133,12 @@ namespace Retroactiune.Controllers
/// <param name="limit">If set, it will limit the results to N items. Allowed range is 1-1000.</param>
/// <returns>A Ok result with a list of <see cref="FeedbackReceiverOutDto"/>.</returns>
/// <response code="200">The a list is returned.</response>
/// <response code="400">The request is invalid.</response>
/// <response code="400">The request is invalid.</response>
[HttpGet]
[Authorize]
[ProducesResponseType(typeof(IEnumerable<FeedbackReceiverOutDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(BasicResponse), StatusCodes.Status400BadRequest)]
[ProducesResponseType( StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> List([FromQuery] IEnumerable<string> filter,
[RangeAttribute(1, int.MaxValue, ErrorMessage = "offset is out of range, allowed ranges [1-IntMax]"),
FromQuery]
@ -148,8 +157,10 @@ namespace Retroactiune.Controllers
/// <response code="400">The request is invalid.</response>
/// <returns></returns>
[HttpDelete]
[Authorize]
[ProducesResponseType(typeof(NoContentResult), StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(BasicResponse), StatusCodes.Status400BadRequest)]
[ProducesResponseType( StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> DeleteMany([Required] IEnumerable<string> ids)
{
try
@ -168,80 +179,5 @@ namespace Retroactiune.Controllers
});
}
}
/// <summary>
/// Add Feedback to a FeedbackReceiver.
/// </summary>
/// <param name="feedbackInDto">The feedback dto.</param>
/// <response code="200">The feedback has been added.</response>
/// <response code="400">The request is invalid.</response>
/// <returns></returns>
[HttpPost("feedbacks")]
[ProducesResponseType(typeof(NoContentResult), StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(BasicResponse), StatusCodes.Status400BadRequest)]
public async Task<IActionResult> AddFeedback([FromBody] FeedbackInDto feedbackInDto)
{
var tokenEnum = await _tokensService.FindAsync(new TokenListFilters
{
Ids = new[] {feedbackInDto.TokenId}
});
var tokens = (tokenEnum as Token[] ?? tokenEnum.ToArray());
if (tokens.Length == 0)
{
return BadRequest(new BasicResponse
{
Message = "Token not found."
});
}
var token = tokens[0];
var receivers = await _feedbackReceiversService.FindAsync(
new[] {token.FeedbackReceiverId}, limit: 1
);
var feedbackReceivers = receivers as FeedbackReceiver[] ?? receivers.ToArray();
if (!feedbackReceivers.Any())
{
return BadRequest(new BasicResponse
{
Message = $"FeedbackReceiver with id {token.FeedbackReceiverId} not found."
});
}
if (!token.IsValid(feedbackReceivers[0]))
{
return BadRequest(new BasicResponse
{
Message = "Token is invalid."
});
}
var feedback = _mapper.Map<Feedback>(feedbackInDto);
await Task.WhenAll(_tokensService.MarkTokenAsUsedAsync(token),
_feedbacksService.AddFeedbackAsync(feedback, feedbackReceivers[0]));
return Ok();
}
/// <summary>
/// Returns the Feedbacks of a FeedbackReceiver. See <see cref="Feedback"/> and <see cref="FeedbackReceiver"/>.
/// </summary>
/// <param name="guid">The guid of the FeedbackReceiver.</param>
/// <param name="filters">Query filters for filtering the response.</param>
/// <response code="200">The feedback has been added.</response>
/// <response code="400">The request is invalid.</response>
/// <returns></returns>
[HttpGet("{guid}/feedbacks")]
[ProducesResponseType(typeof(NoContentResult), StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(BasicResponse), StatusCodes.Status400BadRequest)]
public async Task<IActionResult> GetFeedbacks(string guid, [FromQuery] ListFeedbacksFiltersDto filters)
{
// TODO: Unit & Integration test.
var feedbacksListFilters = _mapper.Map<FeedbacksListFilters>(filters);
feedbacksListFilters.FeedbackReceiverId = guid;
var response = await _feedbacksService.GetFeedbacksAsync(feedbacksListFilters);
return Ok(response);
}
}
}

View file

@ -1,8 +1,10 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
@ -38,8 +40,10 @@ namespace Retroactiune.Controllers
/// <response code="200">A list of tokens.</response>
/// <response code="400">The request is invalid.</response>
[HttpGet]
[Authorize]
[ProducesResponseType(typeof(IEnumerable<Token>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType( StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> ListTokens([FromQuery] ListTokensFiltersDto filtersDto)
{
try
@ -67,8 +71,10 @@ namespace Retroactiune.Controllers
/// <response code="200">Returns ok.</response>
/// <response code="400">If the items is invalid</response>
[HttpPost]
[Authorize]
[ProducesResponseType(typeof(BasicResponse), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType( StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> GenerateTokens([Required] GenerateTokensDto generateTokensDto)
{
var feedbackReceiverId = generateTokensDto.FeedbackReceiverId;
@ -98,8 +104,10 @@ namespace Retroactiune.Controllers
/// <response code="404">The request is invalid.</response>
/// <returns></returns>
[HttpDelete]
[Authorize]
[ProducesResponseType(typeof(NoContentResult), StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(BasicResponse), StatusCodes.Status400BadRequest)]
[ProducesResponseType( StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> DeleteTokens([Required] IEnumerable<string> tokenIds)
{
try
@ -123,10 +131,12 @@ namespace Retroactiune.Controllers
/// <param name="guid">The guid of the item to be deleted.</param>
/// <returns>A NoContent result.</returns>
/// <response code="204">The delete is submitted.</response>
/// <response code="400">The request is invalid.</response>
/// <response code="400">The request is invalid.</response>
[Authorize]
[HttpDelete("{guid}")]
[ProducesResponseType(typeof(NoContentResult), StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType( StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> DeleteToken(
[StringLength(24, ErrorMessage = "invalid guid, must be 24 characters", MinimumLength = 24)]
string guid)
@ -145,5 +155,40 @@ namespace Retroactiune.Controllers
});
}
}
/// <summary>
/// Checks if a token is valid or not.
/// </summary>
/// <response code="200">The the result of the check.</response>
/// <response code="400">The request is invalid.</response>
[HttpGet("{guid}/check")]
[ProducesResponseType(typeof(CheckTokenDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> CheckToken(
[StringLength(24, ErrorMessage = "invalid guid, must be 24 characters", MinimumLength = 24)]
string guid
)
{
try
{
var response = await _tokensService.FindAsync(new TokenListFilters
{
Ids = new[] {guid}
});
var token = response.ElementAt(0);
return Ok(new CheckTokenDto
{
IsValid = token.IsValid()
});
}
catch (ArgumentOutOfRangeException)
{
_logger.LogWarning("Invalid token {Guid}", guid);
return Ok(new CheckTokenDto
{
IsValid = false
});
}
}
}
}

View file

@ -0,0 +1,7 @@
namespace Retroactiune.DataTransferObjects
{
public class CheckTokenDto
{
public bool IsValid { get; set; }
}
}

View file

@ -7,7 +7,7 @@ namespace Retroactiune.DataTransferObjects
/// </summary>
public class ListFeedbacksFiltersDto
{
public uint Rating { get; set; }
public uint? Rating { get; set; }
public DateTime? CreatedAfter { get; set; }
public DateTime? CreatedBefore { get; set; }
}

View file

@ -1,26 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<RootNamespace>Retroactiune</RootNamespace>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>1591</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AutoMapper" Version="10.1.1" />
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="8.1.1" />
<PackageReference Include="MongoDB.Driver" Version="2.12.3" />
<PackageReference Include="prometheus-net" Version="4.2.0" />
<PackageReference Include="prometheus-net.AspNetCore" Version="4.2.0" />
<PackageReference Include="Sentry.AspNetCore" Version="3.8.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="5.6.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Retroactiune.Core\Retroactiune.Core.csproj" />
<ProjectReference Include="..\Retroactiune.Infrastructure\Retroactiune.Infrastructure.csproj" />
</ItemGroup>
</Project>
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<RootNamespace>Retroactiune</RootNamespace>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>1591</NoWarn>
<UserSecretsId>0efb2158-cb38-4e00-9900-48a0fa4ce3c1</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AutoMapper" Version="10.1.1" />
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="8.1.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="3.1.17" />
<PackageReference Include="MongoDB.Driver" Version="2.12.3" />
<PackageReference Include="prometheus-net" Version="4.2.0" />
<PackageReference Include="prometheus-net.AspNetCore" Version="4.2.0" />
<PackageReference Include="Sentry.AspNetCore" Version="3.8.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="5.6.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Retroactiune.Core\Retroactiune.Core.csproj" />
<ProjectReference Include="..\Retroactiune.Infrastructure\Retroactiune.Infrastructure.csproj" />
</ItemGroup>
</Project>

View file

@ -1,6 +1,8 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
@ -8,6 +10,8 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
using MongoDB.Driver;
using Prometheus;
using Retroactiune.Core.Interfaces;
@ -20,8 +24,6 @@ namespace Retroactiune
[ExcludeFromCodeCoverage]
public class Startup
{
// TODO: External auth provider.
// TODO: UI?
public Startup(IConfiguration configuration)
{
Configuration = configuration;
@ -51,12 +53,51 @@ namespace Retroactiune
return new MongoClient(settings.Value.ConnectionString);
});
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = Configuration.GetSection("AuthorizationProvider:Domain").Value,
ValidAudience = Configuration.GetSection("AuthorizationProvider:Audience").Value,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(Configuration.GetSection("AuthorizationProvider:SymmetricSecurityKey")
.Value))
};
});
// WebAPI
services.AddControllers();
services.AddSwaggerGen(c =>
{
var filePath = Path.Combine(AppContext.BaseDirectory, "Retroactiune.WebAPI.xml");
c.IncludeXmlComments(filePath);
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Type = SecuritySchemeType.Http,
BearerFormat = "JWT",
In = ParameterLocation.Header,
Scheme = "bearer",
Description = "Please insert JWT token into field"
});
c.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer"
}
},
new string[] { }
}
});
});
}
@ -69,7 +110,7 @@ namespace Retroactiune
}
app.UseMetricServer();
app.UseSwagger();
app.UseSwaggerUI(c =>
{
@ -78,14 +119,14 @@ namespace Retroactiune
});
app.UseHttpsRedirection();
app.UseSentryTracing();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints => { endpoints.MapControllers(); });
logger.LogInformation("Running");
}
}

View file

@ -0,0 +1,33 @@
using System.Diagnostics.CodeAnalysis;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Retroactiune
{
public class TestAuthenticationOptions : AuthenticationSchemeOptions
{
}
[SuppressMessage("ReSharper", "ClassNeverInstantiated.Global")]
public class TestTokenAuthenticationHandler : AuthenticationHandler<TestAuthenticationOptions>
{
public TestTokenAuthenticationHandler(IOptionsMonitor<TestAuthenticationOptions> options, ILoggerFactory logger,
UrlEncoder encoder, ISystemClock clock)
: base(options, logger, encoder, clock)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new[] {new Claim("token", "allow_all")};
var identity = new ClaimsIdentity(claims, nameof(TestTokenAuthenticationHandler));
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
}

View file

@ -42,7 +42,12 @@ namespace Retroactiune
var settings = i.GetService<IOptions<DatabaseSettings>>();
return new MongoClient(settings.Value.ConnectionString);
});
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = "Bearer";
}).AddScheme<TestAuthenticationOptions, TestTokenAuthenticationHandler> ("Bearer", o => { });
// WebAPI
services.AddControllers();
}
@ -52,6 +57,7 @@ namespace Retroactiune
{
app.UseHttpsRedirection();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints => { endpoints.MapControllers(); });
}

View file

@ -1,4 +1,9 @@
{
"AuthorizationProvider": {
"Domain": "Your AuthProvider Domain",
"Audience": "Your API Url",
"SymmetricSecurityKey": "HS256 secret"
},
"Sentry": {
"Dsn": "",
"MaxRequestBodySize": "Always",