diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..eab94829 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*/.vs/* +.vs/* +*.user +*/obj/* +*/bin/* \ No newline at end of file diff --git a/RestaurantReviews.API.Tests/RestaurantControllerTests.cs b/RestaurantReviews.API.Tests/RestaurantControllerTests.cs new file mode 100644 index 00000000..d2f820c0 --- /dev/null +++ b/RestaurantReviews.API.Tests/RestaurantControllerTests.cs @@ -0,0 +1,65 @@ +using Moq; +using RestaurantReviews.API.Controllers; +using RestaurantReviews.Interfaces.Business; +using RestaurantReviews.Interfaces.Models; +using RestaurantReviews.Models; +using System.Collections.Generic; +using Xunit; + +namespace RestaurantReviews.API.Tests +{ + public class RestaurantControllerTests + { + [Fact] + public void Get_ShouldReturnResultsOfManagerGetAll() + { + // Setup + (var mockManager, var controller) = SetupMocksAndController(); + var expected = new List(); + mockManager.Setup(y => y.GetAll()).Returns(expected); + + // Execute + var actionResult = controller.Get(); + + // Assert + var actual = TestHelper.GetOkResult(actionResult); + Assert.Equal(expected, actual); + } + + [Fact] + public void GetById_ShouldReturnResultOfManagerGetById() + { + // Setup + (var mockManager, var controller) = SetupMocksAndController(); + var expected = new Restaurant(); + mockManager.Setup(y => y.GetById(It.IsAny())).Returns(expected); + + // Execute + var actionResult = controller.Get(0); + + // Assert + var actual = TestHelper.GetOkResult(actionResult); + Assert.Equal(expected, actual); + } + + [Fact] + public void Post_ShouldCallManagerCreateWithCorrectModel() + { + // Setup + (var mockManager, var controller) = SetupMocksAndController(); + var newModel = new Restaurant(); + + // Execute + controller.Post(newModel); + + // Assert + mockManager.Verify(c => c.Create(newModel), Times.Once()); + } + + (Mock, RestaurantController) SetupMocksAndController() + { + var mockManager = new Mock(); + return (mockManager, new RestaurantController(mockManager.Object)); + } + } +} diff --git a/RestaurantReviews.API.Tests/RestaurantReviews.API.Tests.csproj b/RestaurantReviews.API.Tests/RestaurantReviews.API.Tests.csproj new file mode 100644 index 00000000..ea27cb5e --- /dev/null +++ b/RestaurantReviews.API.Tests/RestaurantReviews.API.Tests.csproj @@ -0,0 +1,22 @@ + + + + netcoreapp2.2 + + false + + + + + + + + + + + + + + + + diff --git a/RestaurantReviews.API.Tests/ReviewModelValidatorTests.cs b/RestaurantReviews.API.Tests/ReviewModelValidatorTests.cs new file mode 100644 index 00000000..5a9de5eb --- /dev/null +++ b/RestaurantReviews.API.Tests/ReviewModelValidatorTests.cs @@ -0,0 +1,79 @@ +using Moq; +using RestaurantReviews.Business.Validators; +using RestaurantReviews.Interfaces.Models; +using RestaurantReviews.Interfaces.Repositories; +using RestaurantReviews.Models; +using System; +using System.Linq; +using Xunit; + +namespace RestaurantReviews.API.Tests +{ + public class ReviewModelValidatorTests + { + [Fact] + public void Get_ShouldPassValidationOnValidInput() + { + // Setup + (var validator, var mockRestaurantRepository, var mockUserRepository) = SetupMocksAndValidator(); + mockRestaurantRepository.Setup(p => p.GetById(It.IsAny())).Returns(new Restaurant()); + mockUserRepository.Setup(p => p.GetById(It.IsAny())).Returns(new User()); + + // Execute + var errors = validator.Validate(new Review { Content = Guid.NewGuid().ToString() }); + + // Assert + var actualHasErrors = errors?.Any() == true; + Assert.False(actualHasErrors); + } + + [Fact] + public void Get_ShouldFailIfContentIsNull() + { + // Setup + (var validator, var mockRestaurantRepository, var mockUserRepository) = SetupMocksAndValidator(); + + // Execute + var errors = validator.Validate(new Review()); + + // Assert + Assert.True(errors?.Any(p => p.Contains("Content is required"))); // <- this is not ideal, but didn't want to implement a more complex error object + } + + [Fact] + public void Get_ShouldFailIfRestaurantIdIsInvalid() + { + // Setup + (var validator, var mockRestaurantRepository, var mockUserRepository) = SetupMocksAndValidator(); + mockRestaurantRepository.Setup(p => p.GetById(It.IsAny())).Returns((IRestaurant)null); + + // Execute + var errors = validator.Validate(new Review()); + + // Assert + Assert.True(errors?.Any(p => p.Contains("RestaurantId must be associated with an existing restaurant"))); // <- this is not ideal, but didn't want to implement a more complex error object + } + + [Fact] + public void Get_ShouldFailIfUserIdIsInvalid() + { + // Setup + (var validator, var mockRestaurantRepository, var mockUserRepository) = SetupMocksAndValidator(); + mockUserRepository.Setup(p => p.GetById(It.IsAny())).Returns((IUser)null); + + // Execute + var errors = validator.Validate(new Review()); + + // Assert + Assert.True(errors?.Any(p => p.Contains("UserId must be associated with an existing user"))); // <- this is not ideal, but didn't want to implement a more complex error object + } + + (ReviewModelValidator validator, Mock mockRestaurantRepository, Mock mockUserRepository) SetupMocksAndValidator() + { + var mockRestaurantRepository = new Mock(); + var mockUserRepository = new Mock(); + var validator = new ReviewModelValidator(mockRestaurantRepository.Object, mockUserRepository.Object); + return (validator, mockRestaurantRepository, mockUserRepository); + } + } +} diff --git a/RestaurantReviews.API.Tests/TestHelper.cs b/RestaurantReviews.API.Tests/TestHelper.cs new file mode 100644 index 00000000..128262f6 --- /dev/null +++ b/RestaurantReviews.API.Tests/TestHelper.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Mvc; +using Xunit; + +namespace RestaurantReviews.API.Tests +{ + public static class TestHelper + { + public static object GetOkResult(ActionResult actionResult) + { + Assert.NotNull(actionResult); + Assert.NotNull(actionResult.Result); + var okObjectResult = Assert.IsType(actionResult.Result); + return okObjectResult.Value; + } + } +} diff --git a/RestaurantReviews.API.sln b/RestaurantReviews.API.sln new file mode 100644 index 00000000..e5801c75 --- /dev/null +++ b/RestaurantReviews.API.sln @@ -0,0 +1,55 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.29201.188 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RestaurantReviews.API", "RestaurantReviews.API\RestaurantReviews.API.csproj", "{602795A7-B266-4EBB-A969-BDF2E4B6B330}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RestaurantReviews.Interfaces", "RestaurantReviews.Interfaces\RestaurantReviews.Interfaces.csproj", "{40360CB7-5DD8-43AE-8E82-0496E72B9E61}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RestaurantReviews.JsonData", "RestaurantReviews.JsonData\RestaurantReviews.JsonData.csproj", "{6B6C94AC-65FC-4337-B3BD-3A49B28FFB78}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RestaurantReviews.API.Tests", "RestaurantReviews.API.Tests\RestaurantReviews.API.Tests.csproj", "{79018B48-3CFC-4621-BDD9-0CCD9326FC77}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RestaurantReviews.Models", "RestaurantReviews.Models\RestaurantReviews.Models.csproj", "{F03EB321-4875-4271-90B8-DB80A1ADD0A4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RestaurantReviews.Business", "RestaurantReviews.Business\RestaurantReviews.Business.csproj", "{7EA51CB2-275F-4671-8A36-AEF4A13F9FED}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {602795A7-B266-4EBB-A969-BDF2E4B6B330}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {602795A7-B266-4EBB-A969-BDF2E4B6B330}.Debug|Any CPU.Build.0 = Debug|Any CPU + {602795A7-B266-4EBB-A969-BDF2E4B6B330}.Release|Any CPU.ActiveCfg = Release|Any CPU + {602795A7-B266-4EBB-A969-BDF2E4B6B330}.Release|Any CPU.Build.0 = Release|Any CPU + {40360CB7-5DD8-43AE-8E82-0496E72B9E61}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {40360CB7-5DD8-43AE-8E82-0496E72B9E61}.Debug|Any CPU.Build.0 = Debug|Any CPU + {40360CB7-5DD8-43AE-8E82-0496E72B9E61}.Release|Any CPU.ActiveCfg = Release|Any CPU + {40360CB7-5DD8-43AE-8E82-0496E72B9E61}.Release|Any CPU.Build.0 = Release|Any CPU + {6B6C94AC-65FC-4337-B3BD-3A49B28FFB78}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6B6C94AC-65FC-4337-B3BD-3A49B28FFB78}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6B6C94AC-65FC-4337-B3BD-3A49B28FFB78}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6B6C94AC-65FC-4337-B3BD-3A49B28FFB78}.Release|Any CPU.Build.0 = Release|Any CPU + {79018B48-3CFC-4621-BDD9-0CCD9326FC77}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {79018B48-3CFC-4621-BDD9-0CCD9326FC77}.Debug|Any CPU.Build.0 = Debug|Any CPU + {79018B48-3CFC-4621-BDD9-0CCD9326FC77}.Release|Any CPU.ActiveCfg = Release|Any CPU + {79018B48-3CFC-4621-BDD9-0CCD9326FC77}.Release|Any CPU.Build.0 = Release|Any CPU + {F03EB321-4875-4271-90B8-DB80A1ADD0A4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F03EB321-4875-4271-90B8-DB80A1ADD0A4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F03EB321-4875-4271-90B8-DB80A1ADD0A4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F03EB321-4875-4271-90B8-DB80A1ADD0A4}.Release|Any CPU.Build.0 = Release|Any CPU + {7EA51CB2-275F-4671-8A36-AEF4A13F9FED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7EA51CB2-275F-4671-8A36-AEF4A13F9FED}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7EA51CB2-275F-4671-8A36-AEF4A13F9FED}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7EA51CB2-275F-4671-8A36-AEF4A13F9FED}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {CA8F1049-F118-4248-8449-DB1360411DE6} + EndGlobalSection +EndGlobal diff --git a/RestaurantReviews.API/Controllers/RestaurantController.cs b/RestaurantReviews.API/Controllers/RestaurantController.cs new file mode 100644 index 00000000..01da81f0 --- /dev/null +++ b/RestaurantReviews.API/Controllers/RestaurantController.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Mvc; +using RestaurantReviews.Interfaces.Business; +using RestaurantReviews.Models; +using System.Collections.Generic; + +namespace RestaurantReviews.API.Controllers +{ + [Route("api/[controller]")] + [ApiController] + public class RestaurantController : ControllerBase + { + private readonly IRestaurantManager _restaurantManager; + + public RestaurantController(IRestaurantManager restaurantManager) + { + _restaurantManager = restaurantManager; + } + + // GET api/restaurants + [HttpGet] + public ActionResult> Get() + { + return Ok(_restaurantManager.GetAll()); + } + + // GET api/restaurants/5 + [HttpGet("{id}")] + public ActionResult Get(int id) + { + return Ok(_restaurantManager.GetById(id)); + } + + // POST api/restaurants + [HttpPost] + public void Post([FromBody] Restaurant restaurant) + { + _restaurantManager.Create(restaurant); + } + } +} diff --git a/RestaurantReviews.API/Controllers/ReviewController.cs b/RestaurantReviews.API/Controllers/ReviewController.cs new file mode 100644 index 00000000..2065faa3 --- /dev/null +++ b/RestaurantReviews.API/Controllers/ReviewController.cs @@ -0,0 +1,54 @@ +using Microsoft.AspNetCore.Mvc; +using RestaurantReviews.Interfaces.Business; +using RestaurantReviews.Models; +using System.Collections.Generic; + +namespace RestaurantReviews.API.Controllers +{ + [Route("api/[controller]")] + [ApiController] + public class ReviewController : ControllerBase + { + private readonly IReviewManager _reviewManager; + + public ReviewController(IReviewManager reviewManager) + { + _reviewManager = reviewManager; + } + + // GET api/review + [HttpGet] + public ActionResult> Get() + { + return Ok(_reviewManager.GetAll()); + } + + // GET api/review/5 + [HttpGet("{id}")] + public ActionResult Get(int id) + { + return Ok(_reviewManager.GetById(id)); + } + + // GET api/review/user/5 + [HttpGet("user/{userId}")] + public ActionResult GetByUserId(int userId) + { + return Ok(_reviewManager.GetByUserId(userId)); + } + + // POST api/review + [HttpPost] + public void Post([FromBody] Review review) + { + _reviewManager.Create(review); + } + + // DELETE api/review/5 + [HttpDelete("{id}")] + public void Delete(int id) + { + _reviewManager.Delete(id); + } + } +} diff --git a/RestaurantReviews.API/Controllers/UserController.cs b/RestaurantReviews.API/Controllers/UserController.cs new file mode 100644 index 00000000..1b179835 --- /dev/null +++ b/RestaurantReviews.API/Controllers/UserController.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Mvc; +using RestaurantReviews.Interfaces.Repositories; +using RestaurantReviews.Models; +using System.Collections.Generic; + +namespace UserReviews.API.Controllers +{ + [Route("api/[controller]")] + [ApiController] + public class UserController : ControllerBase + { + private readonly IUserRepository _repository; + + public UserController(IUserRepository userRepository) + { + _repository = userRepository; + } + + // GET api/users + [HttpGet] + public ActionResult> Get() + { + return Ok(_repository.GetAll()); + } + + // GET api/users/5 + [HttpGet("{id}")] + public ActionResult Get(int id) + { + return Ok(_repository.GetById(id)); + } + + // POST api/users + [HttpPost] + public void Post([FromBody] User user) + { + _repository.Create(user); + } + } +} diff --git a/RestaurantReviews.API/Data/restaurants.json b/RestaurantReviews.API/Data/restaurants.json new file mode 100644 index 00000000..32ed3c24 --- /dev/null +++ b/RestaurantReviews.API/Data/restaurants.json @@ -0,0 +1,17 @@ +[ + { + "$type": "RestaurantReviews.Models.Restaurant, RestaurantReviews.Models", + "Id": 0, + "City": "Las Vegas" + }, + { + "$type": "RestaurantReviews.Models.Restaurant, RestaurantReviews.Models", + "Id": 1, + "City": "Pittsburgh" + }, + { + "$type": "RestaurantReviews.Models.Restaurant, RestaurantReviews.Models", + "Id": 2, + "City": "Cleveland" + } +] \ No newline at end of file diff --git a/RestaurantReviews.API/Data/reviews.json b/RestaurantReviews.API/Data/reviews.json new file mode 100644 index 00000000..cac0f193 --- /dev/null +++ b/RestaurantReviews.API/Data/reviews.json @@ -0,0 +1,23 @@ +[ + { + "$type": "RestaurantReviews.Models.Review, RestaurantReviews.Models", + "Id": 0, + "RestaurantId": 1, + "UserId": 1, + "Content": "User 1 things R1 is pretty good" + }, + { + "$type": "RestaurantReviews.Models.Review, RestaurantReviews.Models", + "Id": 1, + "RestaurantId": 1, + "UserId": 2, + "Content": "User 2 things R1 is amazing" + }, + { + "$type": "RestaurantReviews.Models.Review, RestaurantReviews.Models", + "Id": 2, + "RestaurantId": 2, + "UserId": 2, + "Content": "User 2 things R2 is grody" + } +] \ No newline at end of file diff --git a/RestaurantReviews.API/Data/users.json b/RestaurantReviews.API/Data/users.json new file mode 100644 index 00000000..3edd511d --- /dev/null +++ b/RestaurantReviews.API/Data/users.json @@ -0,0 +1,14 @@ +[ + { + "$type": "RestaurantReviews.Models.User, RestaurantReviews.Models", + "Id": 0 + }, + { + "$type": "RestaurantReviews.Models.User, RestaurantReviews.Models", + "Id": 1 + }, + { + "$type": "RestaurantReviews.Models.User, RestaurantReviews.Models", + "Id": 2 + } +] \ No newline at end of file diff --git a/RestaurantReviews.API/Program.cs b/RestaurantReviews.API/Program.cs new file mode 100644 index 00000000..115ec695 --- /dev/null +++ b/RestaurantReviews.API/Program.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Hosting; + +namespace RestaurantReviews.API +{ + public class Program + { + public static void Main(string[] args) + { + CreateWebHostBuilder(args).Build().Run(); + } + + public static IWebHostBuilder CreateWebHostBuilder(string[] args) => + WebHost.CreateDefaultBuilder(args) + .UseStartup(); + } +} diff --git a/RestaurantReviews.API/Properties/launchSettings.json b/RestaurantReviews.API/Properties/launchSettings.json new file mode 100644 index 00000000..4552dda9 --- /dev/null +++ b/RestaurantReviews.API/Properties/launchSettings.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:14924", + "sslPort": 44398 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "api/restaurant", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "RestaurantReviews.API": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "api/values", + "applicationUrl": "https://localhost:5001;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} \ No newline at end of file diff --git a/RestaurantReviews.API/RestaurantReviews.API.csproj b/RestaurantReviews.API/RestaurantReviews.API.csproj new file mode 100644 index 00000000..b78cb4f9 --- /dev/null +++ b/RestaurantReviews.API/RestaurantReviews.API.csproj @@ -0,0 +1,24 @@ + + + + netcoreapp2.2 + InProcess + + + + + + + + + + + + + + + + + + + diff --git a/RestaurantReviews.API/Startup.cs b/RestaurantReviews.API/Startup.cs new file mode 100644 index 00000000..f344129c --- /dev/null +++ b/RestaurantReviews.API/Startup.cs @@ -0,0 +1,58 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using RestaurantReviews.Business.Managers; +using RestaurantReviews.Business.Validators; +using RestaurantReviews.Interfaces.Business; +using RestaurantReviews.Interfaces.Models; +using RestaurantReviews.Interfaces.Repositories; +using RestaurantReviews.JsonData; +using RestaurantReviews.JsonData.Repositories; + +namespace RestaurantReviews.API +{ + public class Startup + { + public Startup(IConfiguration configuration) + { + Configuration = configuration; + } + + public IConfiguration Configuration { get; } + + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) + { + services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2); + + // Setup dependency injection + services.AddScoped(); + services.AddScoped, ReviewModelValidator>(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app, IHostingEnvironment env) + { + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + else + { + // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. + app.UseHsts(); + } + + app.UseHttpsRedirection(); + app.UseMvc(); + } + } +} diff --git a/RestaurantReviews.API/appsettings.Development.json b/RestaurantReviews.API/appsettings.Development.json new file mode 100644 index 00000000..e203e940 --- /dev/null +++ b/RestaurantReviews.API/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + } +} diff --git a/RestaurantReviews.API/appsettings.json b/RestaurantReviews.API/appsettings.json new file mode 100644 index 00000000..def9159a --- /dev/null +++ b/RestaurantReviews.API/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/RestaurantReviews.Business/Managers/RestaurantManager.cs b/RestaurantReviews.Business/Managers/RestaurantManager.cs new file mode 100644 index 00000000..ddff9a92 --- /dev/null +++ b/RestaurantReviews.Business/Managers/RestaurantManager.cs @@ -0,0 +1,36 @@ +using RestaurantReviews.Interfaces.Business; +using RestaurantReviews.Interfaces.Models; +using RestaurantReviews.Interfaces.Repositories; +using System; +using System.Collections.Generic; + +namespace RestaurantReviews.Business.Managers +{ + public class RestaurantManager : IRestaurantManager + { + private readonly IRestaurantRepository _restaurantRepository; + + public RestaurantManager(IRestaurantRepository restaurantRepository) + { + _restaurantRepository = restaurantRepository; + } + + public ICollection GetAll() + { + return _restaurantRepository.GetAll(); + } + + public IRestaurant GetById(long id) + { + return _restaurantRepository.GetById(id); + } + + public void Create(IRestaurant restaurant) + { + if (restaurant == null) + throw new ArgumentNullException("restaurant"); + + _restaurantRepository.Create(restaurant); + } + } +} diff --git a/RestaurantReviews.Business/Managers/ReviewManager.cs b/RestaurantReviews.Business/Managers/ReviewManager.cs new file mode 100644 index 00000000..eda0848c --- /dev/null +++ b/RestaurantReviews.Business/Managers/ReviewManager.cs @@ -0,0 +1,58 @@ +using RestaurantReviews.Interfaces.Business; +using RestaurantReviews.Interfaces.Models; +using RestaurantReviews.Interfaces.Repositories; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace RestaurantReviews.Business.Managers +{ + public class ReviewManager : IReviewManager + { + private readonly IReviewRepository _reviewRepository; + private readonly IUserRepository _userRepository; + private readonly IModelValidator _reviewValidator; + + public ReviewManager(IReviewRepository reviewRepository, IUserRepository userRepository, IModelValidator reviewValidator) + { + _reviewRepository = reviewRepository; + _userRepository = userRepository; + _reviewValidator = reviewValidator; + } + + public ICollection GetAll() + { + return _reviewRepository.GetAll(); + } + + public IReview GetById(long id) + { + return _reviewRepository.GetById(id); + } + + public ICollection GetByUserId(int userId) + { + if (_userRepository.GetById(userId) == null) + throw new ArgumentException($"UserId {userId} does not exist"); + + return _reviewRepository.GetByUserId(userId); + } + + public void Create(IReview review) + { + if (review == null) + throw new ArgumentNullException("review"); + + var errors = _reviewValidator.Validate(review); + if (errors?.Any() == true) + throw new Exception(string.Join(Environment.NewLine, errors)); + + _reviewRepository.Create(review); + } + + public void Delete(long id) + { + _reviewRepository.Delete(id); + } + } +} diff --git a/RestaurantReviews.Business/Managers/UserManager.cs b/RestaurantReviews.Business/Managers/UserManager.cs new file mode 100644 index 00000000..f554bc86 --- /dev/null +++ b/RestaurantReviews.Business/Managers/UserManager.cs @@ -0,0 +1,36 @@ +using RestaurantReviews.Interfaces.Business; +using RestaurantReviews.Interfaces.Models; +using RestaurantReviews.Interfaces.Repositories; +using System; +using System.Collections.Generic; + +namespace RestaurantReviews.Business.Managers +{ + public class UserManager : IUserManager + { + private readonly IUserRepository _userRepository; + + public UserManager(IUserRepository userRepository) + { + _userRepository = userRepository; + } + + public ICollection GetAll() + { + return _userRepository.GetAll(); + } + + public IUser GetById(long id) + { + return _userRepository.GetById(id); + } + + public void Create(IUser user) + { + if (user == null) + throw new ArgumentNullException("user"); + + _userRepository.Create(user); + } + } +} diff --git a/RestaurantReviews.Business/RestaurantReviews.Business.csproj b/RestaurantReviews.Business/RestaurantReviews.Business.csproj new file mode 100644 index 00000000..81996faf --- /dev/null +++ b/RestaurantReviews.Business/RestaurantReviews.Business.csproj @@ -0,0 +1,15 @@ + + + + netcoreapp2.2 + + + + + + + + + + + diff --git a/RestaurantReviews.Business/Validators/CustomRules.cs b/RestaurantReviews.Business/Validators/CustomRules.cs new file mode 100644 index 00000000..e857e105 --- /dev/null +++ b/RestaurantReviews.Business/Validators/CustomRules.cs @@ -0,0 +1,29 @@ +using FluentValidation; +using RestaurantReviews.Interfaces.Repositories; + +namespace RestaurantReviews.Business.Validators +{ + internal static class CustomRules + { + public const string InvalidRestaurantIdErrorMessage = "RestaurantId must be associated with an existing restaurant"; + public const string InvalidUserIdErrorMessage = "UserId must be associated with an existing user"; + + internal static IRuleBuilderOptions MustBeAnExistingRestaurantId( + this IRuleBuilder ruleBuilder, + IRestaurantRepository restaurantRepository) + { + return ruleBuilder + .Must(restaurantId => restaurantRepository.GetById(restaurantId) != null) + .WithMessage(InvalidRestaurantIdErrorMessage); + } + + internal static IRuleBuilderOptions MustBeAnExistingUserId( + this IRuleBuilder ruleBuilder, + IUserRepository userRepository) + { + return ruleBuilder + .Must(userId => userRepository.GetById(userId) != null) + .WithMessage(InvalidUserIdErrorMessage); + } + } +} \ No newline at end of file diff --git a/RestaurantReviews.Business/Validators/FluentModelValidator.cs b/RestaurantReviews.Business/Validators/FluentModelValidator.cs new file mode 100644 index 00000000..3d05642e --- /dev/null +++ b/RestaurantReviews.Business/Validators/FluentModelValidator.cs @@ -0,0 +1,24 @@ +using FluentValidation; +using RestaurantReviews.Interfaces.Business; +using System.Collections.Generic; +using System.Linq; + +namespace RestaurantReviews.Business.Validators +{ + public abstract class FluentModelValidator : IModelValidator + { + protected readonly AbstractValidator Validator; + + protected FluentModelValidator() + { + Validator = new InlineValidator(); + } + + public ICollection Validate(T item) + { + var validationResults = Validator.Validate(item); + var errors = validationResults.Errors.Select(p => $"{p.PropertyName}: {p.ErrorMessage}").ToArray(); + return errors; + } + } +} diff --git a/RestaurantReviews.Business/Validators/RestaurantModelValidator.cs b/RestaurantReviews.Business/Validators/RestaurantModelValidator.cs new file mode 100644 index 00000000..dc528665 --- /dev/null +++ b/RestaurantReviews.Business/Validators/RestaurantModelValidator.cs @@ -0,0 +1,17 @@ +using FluentValidation; +using RestaurantReviews.Interfaces.Models; + +namespace RestaurantReviews.Business.Validators +{ + public class RestaurantModelValidator : FluentModelValidator + { + public const string CityRequiredErrorMessage = "City is required"; + + public RestaurantModelValidator() + { + Validator.RuleFor(p => p.City) + .NotEmpty() + .WithMessage(CityRequiredErrorMessage); + } + } +} diff --git a/RestaurantReviews.Business/Validators/ReviewModelValidator.cs b/RestaurantReviews.Business/Validators/ReviewModelValidator.cs new file mode 100644 index 00000000..5f1eae55 --- /dev/null +++ b/RestaurantReviews.Business/Validators/ReviewModelValidator.cs @@ -0,0 +1,24 @@ +using FluentValidation; +using RestaurantReviews.Interfaces.Models; +using RestaurantReviews.Interfaces.Repositories; + +namespace RestaurantReviews.Business.Validators +{ + public class ReviewModelValidator : FluentModelValidator + { + public const string ContentRequiredErrorMessage = "Content is required"; + + public ReviewModelValidator(IRestaurantRepository restaurantRepository, IUserRepository userRepository) + { + Validator.RuleFor(p => p.Content) + .NotEmpty() + .WithMessage(ContentRequiredErrorMessage); + + Validator.RuleFor(p => p.RestaurantId) + .MustBeAnExistingRestaurantId(restaurantRepository); + + Validator.RuleFor(p => p.UserId) + .MustBeAnExistingUserId(userRepository); + } + } +} diff --git a/RestaurantReviews.Interfaces/Business/IManager.cs b/RestaurantReviews.Interfaces/Business/IManager.cs new file mode 100644 index 00000000..1c48f2a9 --- /dev/null +++ b/RestaurantReviews.Interfaces/Business/IManager.cs @@ -0,0 +1,12 @@ +using RestaurantReviews.Interfaces.Models; +using System.Collections.Generic; + +namespace RestaurantReviews.Interfaces.Business +{ + public interface IManager where T: IModel + { + ICollection GetAll(); + T GetById(long id); + void Create(T item); + } +} diff --git a/RestaurantReviews.Interfaces/Business/IModelValidator.cs b/RestaurantReviews.Interfaces/Business/IModelValidator.cs new file mode 100644 index 00000000..34621ea1 --- /dev/null +++ b/RestaurantReviews.Interfaces/Business/IModelValidator.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace RestaurantReviews.Interfaces.Business +{ + public interface IModelValidator + { + ICollection Validate(T model); + } +} diff --git a/RestaurantReviews.Interfaces/Business/IRestaurantManager.cs b/RestaurantReviews.Interfaces/Business/IRestaurantManager.cs new file mode 100644 index 00000000..301990d7 --- /dev/null +++ b/RestaurantReviews.Interfaces/Business/IRestaurantManager.cs @@ -0,0 +1,6 @@ +using RestaurantReviews.Interfaces.Models; + +namespace RestaurantReviews.Interfaces.Business +{ + public interface IRestaurantManager : IManager { } +} diff --git a/RestaurantReviews.Interfaces/Business/IReviewManager.cs b/RestaurantReviews.Interfaces/Business/IReviewManager.cs new file mode 100644 index 00000000..7d6bdf60 --- /dev/null +++ b/RestaurantReviews.Interfaces/Business/IReviewManager.cs @@ -0,0 +1,11 @@ +using RestaurantReviews.Interfaces.Models; +using System.Collections.Generic; + +namespace RestaurantReviews.Interfaces.Business +{ + public interface IReviewManager : IManager + { + ICollection GetByUserId(int userId); + void Delete(long id); + } +} diff --git a/RestaurantReviews.Interfaces/Business/IUserManager.cs b/RestaurantReviews.Interfaces/Business/IUserManager.cs new file mode 100644 index 00000000..9da01fe4 --- /dev/null +++ b/RestaurantReviews.Interfaces/Business/IUserManager.cs @@ -0,0 +1,6 @@ +using RestaurantReviews.Interfaces.Models; + +namespace RestaurantReviews.Interfaces.Business +{ + public interface IUserManager : IManager { } +} diff --git a/RestaurantReviews.Interfaces/Models/IModel.cs b/RestaurantReviews.Interfaces/Models/IModel.cs new file mode 100644 index 00000000..1312a3d7 --- /dev/null +++ b/RestaurantReviews.Interfaces/Models/IModel.cs @@ -0,0 +1,7 @@ +namespace RestaurantReviews.Interfaces.Models +{ + public interface IModel + { + long Id { get; set; } + } +} \ No newline at end of file diff --git a/RestaurantReviews.Interfaces/Models/IRestaurant.cs b/RestaurantReviews.Interfaces/Models/IRestaurant.cs new file mode 100644 index 00000000..f579d551 --- /dev/null +++ b/RestaurantReviews.Interfaces/Models/IRestaurant.cs @@ -0,0 +1,7 @@ +namespace RestaurantReviews.Interfaces.Models +{ + public interface IRestaurant : IModel + { + string City { get; set; } + } +} \ No newline at end of file diff --git a/RestaurantReviews.Interfaces/Models/IReview.cs b/RestaurantReviews.Interfaces/Models/IReview.cs new file mode 100644 index 00000000..0825890e --- /dev/null +++ b/RestaurantReviews.Interfaces/Models/IReview.cs @@ -0,0 +1,9 @@ +namespace RestaurantReviews.Interfaces.Models +{ + public interface IReview : IModel + { + string Content { get; set; } + long RestaurantId { get; set; } + long UserId { get; set; } + } +} \ No newline at end of file diff --git a/RestaurantReviews.Interfaces/Models/IUser.cs b/RestaurantReviews.Interfaces/Models/IUser.cs new file mode 100644 index 00000000..3da09281 --- /dev/null +++ b/RestaurantReviews.Interfaces/Models/IUser.cs @@ -0,0 +1,7 @@ +namespace RestaurantReviews.Interfaces.Models +{ + public interface IUser : IModel + { + // For simplicity, not including common user properties (username, password, email, etc) + } +} \ No newline at end of file diff --git a/RestaurantReviews.Interfaces/Repositories/IContext.cs b/RestaurantReviews.Interfaces/Repositories/IContext.cs new file mode 100644 index 00000000..8875b06c --- /dev/null +++ b/RestaurantReviews.Interfaces/Repositories/IContext.cs @@ -0,0 +1,11 @@ +using RestaurantReviews.Interfaces.Models; + +namespace RestaurantReviews.Interfaces.Repositories +{ + public interface IContext + { + IDataSet RestaurantDataSet { get; } + IDataSet UserDataSet { get; } + IDataSet ReviewDataSet { get; } + } +} \ No newline at end of file diff --git a/RestaurantReviews.Interfaces/Repositories/IDataSet.cs b/RestaurantReviews.Interfaces/Repositories/IDataSet.cs new file mode 100644 index 00000000..c853d656 --- /dev/null +++ b/RestaurantReviews.Interfaces/Repositories/IDataSet.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; + +namespace RestaurantReviews.Interfaces.Repositories +{ + public interface IDataSet + { + ICollection GetAll(); + void Save(ICollection contents); + } +} \ No newline at end of file diff --git a/RestaurantReviews.Interfaces/Repositories/IRepository.cs b/RestaurantReviews.Interfaces/Repositories/IRepository.cs new file mode 100644 index 00000000..fd79f990 --- /dev/null +++ b/RestaurantReviews.Interfaces/Repositories/IRepository.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace RestaurantReviews.Interfaces.Repositories +{ + public interface IRepository + { + ICollection GetAll(); + T GetById(long id); + long Create(T item); + } +} diff --git a/RestaurantReviews.Interfaces/Repositories/IRestaurantRepository.cs b/RestaurantReviews.Interfaces/Repositories/IRestaurantRepository.cs new file mode 100644 index 00000000..e0744149 --- /dev/null +++ b/RestaurantReviews.Interfaces/Repositories/IRestaurantRepository.cs @@ -0,0 +1,6 @@ +using RestaurantReviews.Interfaces.Models; + +namespace RestaurantReviews.Interfaces.Repositories +{ + public interface IRestaurantRepository : IRepository { } +} \ No newline at end of file diff --git a/RestaurantReviews.Interfaces/Repositories/IReviewRepository.cs b/RestaurantReviews.Interfaces/Repositories/IReviewRepository.cs new file mode 100644 index 00000000..2f5856f8 --- /dev/null +++ b/RestaurantReviews.Interfaces/Repositories/IReviewRepository.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using RestaurantReviews.Interfaces.Models; + +namespace RestaurantReviews.Interfaces.Repositories +{ + public interface IReviewRepository : IRepository + { + ICollection GetByUserId(int userId); + void Delete(long id); + } +} \ No newline at end of file diff --git a/RestaurantReviews.Interfaces/Repositories/IUserRepository.cs b/RestaurantReviews.Interfaces/Repositories/IUserRepository.cs new file mode 100644 index 00000000..085ff569 --- /dev/null +++ b/RestaurantReviews.Interfaces/Repositories/IUserRepository.cs @@ -0,0 +1,6 @@ +using RestaurantReviews.Interfaces.Models; + +namespace RestaurantReviews.Interfaces.Repositories +{ + public interface IUserRepository : IRepository { } +} diff --git a/RestaurantReviews.Interfaces/RestaurantReviews.Interfaces.csproj b/RestaurantReviews.Interfaces/RestaurantReviews.Interfaces.csproj new file mode 100644 index 00000000..2bd48b21 --- /dev/null +++ b/RestaurantReviews.Interfaces/RestaurantReviews.Interfaces.csproj @@ -0,0 +1,7 @@ + + + + netcoreapp2.2 + + + diff --git a/RestaurantReviews.JsonData/Context.cs b/RestaurantReviews.JsonData/Context.cs new file mode 100644 index 00000000..2a6306e4 --- /dev/null +++ b/RestaurantReviews.JsonData/Context.cs @@ -0,0 +1,23 @@ +using RestaurantReviews.Interfaces.Models; +using RestaurantReviews.Interfaces.Repositories; + +namespace RestaurantReviews.JsonData +{ + public class Context : IContext + { + private const string restaurantPath = "Data/restaurants.json"; + private const string userPath = "Data/users.json"; + private const string reviewPath = "Data/reviews.json"; + + public IDataSet RestaurantDataSet { get; } + public IDataSet UserDataSet { get; } + public IDataSet ReviewDataSet { get; } + + public Context() + { + RestaurantDataSet = new DataSet(restaurantPath); + UserDataSet = new DataSet(userPath); + ReviewDataSet = new DataSet(reviewPath); + } + } +} \ No newline at end of file diff --git a/RestaurantReviews.JsonData/DataSet.cs b/RestaurantReviews.JsonData/DataSet.cs new file mode 100644 index 00000000..0ae26b02 --- /dev/null +++ b/RestaurantReviews.JsonData/DataSet.cs @@ -0,0 +1,43 @@ +using Newtonsoft.Json; +using RestaurantReviews.Interfaces.Repositories; +using System.Collections.Generic; +using System.IO; + +namespace RestaurantReviews.JsonData +{ + public class DataSet : IDataSet + { + private string path; + + public DataSet(string path) + { + this.path = path; + } + + public ICollection GetAll() + { + if (!File.Exists(path)) + return new List(); + var contents = File.ReadAllText(path); + return JsonConvert.DeserializeObject>( + contents, + new JsonSerializerSettings + { + TypeNameHandling = TypeNameHandling.Objects + } + ); + } + + public void Save(ICollection contents) + { + File.WriteAllText(path, + JsonConvert.SerializeObject(contents, + Formatting.Indented, + new JsonSerializerSettings + { + TypeNameHandling = TypeNameHandling.Objects, + TypeNameAssemblyFormatHandling = TypeNameAssemblyFormatHandling.Simple + })); + } + } +} \ No newline at end of file diff --git a/RestaurantReviews.JsonData/Respositories/RepositoryBase.cs b/RestaurantReviews.JsonData/Respositories/RepositoryBase.cs new file mode 100644 index 00000000..dcfc03aa --- /dev/null +++ b/RestaurantReviews.JsonData/Respositories/RepositoryBase.cs @@ -0,0 +1,47 @@ +using RestaurantReviews.Interfaces.Models; +using RestaurantReviews.Interfaces.Repositories; +using System.Collections.Generic; +using System.Linq; + +namespace RestaurantReviews.JsonData.Repositories +{ + public abstract class RepositoryBase where T : IModel + { + protected readonly IContext context; + private readonly IDataSet dataSet; + + public RepositoryBase(IContext context) + { + this.context = context; + dataSet = GetDataSet(); + } + + public abstract IDataSet GetDataSet(); + public abstract void Update(long id, T t); + + public ICollection GetAll() + { + return dataSet.GetAll(); + } + + public T GetById(long id) + { + return dataSet.GetAll().FirstOrDefault(x => x.Id == id); + } + + public long Create(T q) + { + var contents = dataSet.GetAll(); + q.Id = contents.Any() ? contents.Select(x => x.Id).Max() + 1 : 0; + contents.Add(q); + dataSet.Save(contents); + return q.Id; + } + + public void Delete(long id) + { + var contents = dataSet.GetAll().Where(x => x.Id != id); + dataSet.Save(contents.ToArray()); + } + } +} \ No newline at end of file diff --git a/RestaurantReviews.JsonData/Respositories/RestaurantRepository.cs b/RestaurantReviews.JsonData/Respositories/RestaurantRepository.cs new file mode 100644 index 00000000..99793bda --- /dev/null +++ b/RestaurantReviews.JsonData/Respositories/RestaurantRepository.cs @@ -0,0 +1,26 @@ +using RestaurantReviews.Interfaces.Models; +using RestaurantReviews.Interfaces.Repositories; +using System.Linq; + +namespace RestaurantReviews.JsonData.Repositories +{ + public class RestaurantRepository : RepositoryBase, IRestaurantRepository + { + public RestaurantRepository(IContext context) : base(context) { } + + public override IDataSet GetDataSet() + { + return context.RestaurantDataSet; + } + + public override void Update(long id, IRestaurant q) + { + var contents = GetAll(); + var found = contents.FirstOrDefault(x => x.Id == id); + if (found == null) + return; + found.City = q.City; + context.RestaurantDataSet.Save(contents); + } + } +} \ No newline at end of file diff --git a/RestaurantReviews.JsonData/Respositories/ReviewRepository.cs b/RestaurantReviews.JsonData/Respositories/ReviewRepository.cs new file mode 100644 index 00000000..6e048bf0 --- /dev/null +++ b/RestaurantReviews.JsonData/Respositories/ReviewRepository.cs @@ -0,0 +1,35 @@ +using RestaurantReviews.Interfaces.Models; +using RestaurantReviews.Interfaces.Repositories; +using System.Collections.Generic; +using System.Linq; + +namespace RestaurantReviews.JsonData.Repositories +{ + public class ReviewRepository : RepositoryBase, IReviewRepository + { + public ReviewRepository(IContext context) : base(context) { } + + public override IDataSet GetDataSet() + { + return context.ReviewDataSet; + } + + public override void Update(long id, IReview q) + { + var contents = GetAll(); + var found = contents.FirstOrDefault(x => x.Id == id); + if (found == null) + return; + found.Content = q.Content; + found.RestaurantId = q.RestaurantId; + found.UserId = q.UserId; + context.ReviewDataSet.Save(contents); + } + + public ICollection GetByUserId(int userId) + { + var contents = GetAll(); + return contents.Where(p => p.UserId == userId).ToList(); + } + } +} \ No newline at end of file diff --git a/RestaurantReviews.JsonData/Respositories/UserRepository.cs b/RestaurantReviews.JsonData/Respositories/UserRepository.cs new file mode 100644 index 00000000..632c2494 --- /dev/null +++ b/RestaurantReviews.JsonData/Respositories/UserRepository.cs @@ -0,0 +1,26 @@ +using RestaurantReviews.Interfaces.Models; +using RestaurantReviews.Interfaces.Repositories; +using System.Linq; + +namespace RestaurantReviews.JsonData.Repositories +{ + public class UserRepository : RepositoryBase, IUserRepository + { + public UserRepository(IContext context) : base(context) { } + + public override IDataSet GetDataSet() + { + return context.UserDataSet; + } + + public override void Update(long id, IUser q) + { + var contents = GetAll(); + var found = contents.FirstOrDefault(x => x.Id == id); + if (found == null) + return; + // no properties to update + context.UserDataSet.Save(contents); + } + } +} \ No newline at end of file diff --git a/RestaurantReviews.JsonData/RestaurantReviews.JsonData.csproj b/RestaurantReviews.JsonData/RestaurantReviews.JsonData.csproj new file mode 100644 index 00000000..01ecb48d --- /dev/null +++ b/RestaurantReviews.JsonData/RestaurantReviews.JsonData.csproj @@ -0,0 +1,17 @@ + + + + netcoreapp2.2 + + + + + + + + + ..\..\..\..\..\Program Files\dotnet\sdk\NuGetFallbackFolder\newtonsoft.json\11.0.2\lib\netstandard2.0\Newtonsoft.Json.dll + + + + diff --git a/RestaurantReviews.Models/Restaurant.cs b/RestaurantReviews.Models/Restaurant.cs new file mode 100644 index 00000000..1a23da8e --- /dev/null +++ b/RestaurantReviews.Models/Restaurant.cs @@ -0,0 +1,10 @@ +using RestaurantReviews.Interfaces.Models; + +namespace RestaurantReviews.Models +{ + public class Restaurant : IRestaurant + { + public long Id { get; set; } + public string City { get; set; } + } +} diff --git a/RestaurantReviews.Models/RestaurantReviews.Models.csproj b/RestaurantReviews.Models/RestaurantReviews.Models.csproj new file mode 100644 index 00000000..8f70ca92 --- /dev/null +++ b/RestaurantReviews.Models/RestaurantReviews.Models.csproj @@ -0,0 +1,11 @@ + + + + netcoreapp2.2 + + + + + + + diff --git a/RestaurantReviews.Models/Review.cs b/RestaurantReviews.Models/Review.cs new file mode 100644 index 00000000..95759c07 --- /dev/null +++ b/RestaurantReviews.Models/Review.cs @@ -0,0 +1,12 @@ +using RestaurantReviews.Interfaces.Models; + +namespace RestaurantReviews.Models +{ + public class Review : IReview + { + public long Id { get; set; } + public long RestaurantId { get; set; } + public long UserId { get; set; } + public string Content { get; set; } + } +} diff --git a/RestaurantReviews.Models/User.cs b/RestaurantReviews.Models/User.cs new file mode 100644 index 00000000..ba5ed03c --- /dev/null +++ b/RestaurantReviews.Models/User.cs @@ -0,0 +1,10 @@ +using RestaurantReviews.Interfaces.Models; + +namespace RestaurantReviews.Models +{ + public class User : IUser + { + public long Id { get; set; } + // For simplicity, not including common user properties (username, password, email, etc) + } +}