diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..06262728 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +*.swp +*.*~ +project.lock.json +.DS_Store +*.pyc +nupkg/ + +# Visual Studio Code +.vscode + +# Rider +.idea + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +build/ +bld/ +[Bb]in/ +[Oo]bj/ +[Oo]ut/ +msbuild.log +msbuild.err +msbuild.wrn + +# Visual Studio 2015 +.vs/ \ No newline at end of file diff --git a/RestaurantReviews.Api.UnitTests/ControllerTests/RestaurantControllerTests.cs b/RestaurantReviews.Api.UnitTests/ControllerTests/RestaurantControllerTests.cs new file mode 100644 index 00000000..c3bc93c2 --- /dev/null +++ b/RestaurantReviews.Api.UnitTests/ControllerTests/RestaurantControllerTests.cs @@ -0,0 +1,160 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Moq; +using RestaurantReviews.Api.Controllers; +using RestaurantReviews.Api.DataAccess; +using RestaurantReviews.Api.Models; +using Xunit; + +namespace RestaurantReviews.Api.UnitTests.ControllerTests +{ + public class RestaurantControllerTests + { + [Fact] + public async Task GetListWithNoParamsReturnsList() + { + var mockRestaurantQuery = new Mock(); + mockRestaurantQuery.Setup(q => q.GetRestaurants(null, null)) + .Returns(Task.FromResult(new List {new Restaurant()})); + var controller = new RestaurantController(null, mockRestaurantQuery.Object, null); + + var result = await controller.GetListAsync(); + + Assert.IsType(result.Result); + Assert.IsType>(((OkObjectResult)result.Result).Value); + var resultList = (List)((OkObjectResult) result.Result).Value; + Assert.Single(resultList); + } + + [Fact] + public async Task GetListWithNoCityReturnsBadRequest() + { + var controller = new RestaurantController(null, null, null); + + var result = await controller.GetListAsync(null, "PA"); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task GetListWithNoStateReturnsBadRequest() + { + var controller = new RestaurantController(null, null, null); + + var result = await controller.GetListAsync("Pittsburgh"); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task GetListWithParamsReturnsList() + { + var mockRestaurantQuery = new Mock(); + mockRestaurantQuery.Setup(q => + q.GetRestaurants(It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new List {TestData.McDonalds })); + var controller = new RestaurantController(null, mockRestaurantQuery.Object, null); + + var result = await controller.GetListAsync("Pittsburgh", "PA"); + + Assert.IsType(result.Result); + Assert.IsType>(((OkObjectResult)result.Result).Value); + var resultList = (List)((OkObjectResult) result.Result).Value; + Assert.Single(resultList); + } + + [Fact] + public async Task GetSingleWithValidIdReturnsRestaurant() + { + var mockRestaurantQuery = new Mock(); + mockRestaurantQuery.Setup(q => q.GetRestaurant(TestData.McDonalds.Id)) + .Returns(Task.FromResult(TestData.McDonalds)); + var controller = new RestaurantController(null, mockRestaurantQuery.Object, null); + + var result = await controller.GetAsync(TestData.McDonalds.Id); + + Assert.IsType(result.Result); + Assert.IsType(((OkObjectResult)result.Result).Value); + var restaurant = (Restaurant)((OkObjectResult) result.Result).Value; + Assert.Equal(TestData.McDonalds.Name, restaurant.Name); + } + + [Fact] + public async Task GetSingleWithNonexistentIdReturnsNotFound() + { + var mockRestaurantQuery = new Mock(); + mockRestaurantQuery.Setup(q => q.GetRestaurant(TestData.McDonalds.Id)) + .Returns(Task.FromResult(TestData.McDonalds)); + var controller = new RestaurantController(null, mockRestaurantQuery.Object, null); + + var result = await controller.GetAsync(TestData.Wendys.Id); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task GetSingleWithInvalidIdReturnsBadRequest() + { + var controller = new RestaurantController(null, null, null); + + var result = await controller.GetAsync(0); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task PostNewRestaurantThatDoesNotExistReturnsCreated() + { + var mockRestaurantValidator = new Mock(); + mockRestaurantValidator.Setup(v => + v.IsRestaurantValid(It.IsAny())).Returns(true); + var mockRestaurantQuery = new Mock(); + mockRestaurantQuery.Setup(q => q.GetRestaurant(TestData.McDonalds.Name, TestData.McDonalds.City, TestData.McDonalds.State)) + .Returns(Task.FromResult(TestData.McDonalds)); + var mockInsertRestaurant = new Mock(); + var controller = new RestaurantController(mockRestaurantValidator.Object, + mockRestaurantQuery.Object, + mockInsertRestaurant.Object); + + var result = await controller.PostAsync(TestData.Wendys); + + Assert.IsType(result.Result); + mockInsertRestaurant.Verify(i => i.Insert(It.IsAny()), + Times.Once); + } + + [Fact] + public async Task PostNewRestaurantThatIsInvalidReturnsBadRequest() + { + var mockRestaurantValidator = new Mock(); + mockRestaurantValidator.Setup(v => + v.IsRestaurantValid(It.IsAny())).Returns(false); + var controller = new RestaurantController(mockRestaurantValidator.Object, + null, null); + + var result = await controller.PostAsync(TestData.Wendys); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task PostNewRestaurantThatExistsReturnsConflict() + { + var mockRestaurantValidator = new Mock(); + mockRestaurantValidator.Setup(v => + v.IsRestaurantValid(It.IsAny())).Returns(true); + var mockRestaurantQuery = new Mock(); + mockRestaurantQuery.Setup(q => q.GetRestaurant(TestData.McDonalds.Name, TestData.McDonalds.City, TestData.McDonalds.State)) + .Returns(Task.FromResult(TestData.McDonalds)); + var mockInsertRestaurant = new Mock(); + var controller = new RestaurantController(mockRestaurantValidator.Object, + mockRestaurantQuery.Object, + mockInsertRestaurant.Object); + + var result = await controller.PostAsync(TestData.McDonalds); + + Assert.IsType(result.Result); + } + } +} \ No newline at end of file diff --git a/RestaurantReviews.Api.UnitTests/ControllerTests/ReviewControllerTests.cs b/RestaurantReviews.Api.UnitTests/ControllerTests/ReviewControllerTests.cs new file mode 100644 index 00000000..b981180d --- /dev/null +++ b/RestaurantReviews.Api.UnitTests/ControllerTests/ReviewControllerTests.cs @@ -0,0 +1,154 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Moq; +using RestaurantReviews.Api.Controllers; +using RestaurantReviews.Api.DataAccess; +using RestaurantReviews.Api.Models; +using Xunit; + +namespace RestaurantReviews.Api.UnitTests.ControllerTests +{ + public class ReviewControllerTests + { + [Fact] + public async Task GetWithValidIdReturnsReview() + { + var mockReviewQuery = new Mock(); + mockReviewQuery.Setup(q => q.GetReview(TestData.McDonaldsReview.Id)) + .Returns(Task.FromResult(TestData.McDonaldsReview)); + var controller = new ReviewController(null, mockReviewQuery.Object, null, null); + + var result = await controller.GetAsync(TestData.McDonaldsReview.Id); + + Assert.IsType(result.Result); + Assert.IsType(((OkObjectResult)result.Result).Value); + var review = (Review)((OkObjectResult) result.Result).Value; + Assert.Equal(TestData.McDonaldsReview.RatingStars, review.RatingStars); + } + + [Fact] + public async Task GetWithNonexistentIdReturnsNotFound() + { + var mockReviewQuery = new Mock(); + mockReviewQuery.Setup(q => q.GetReview(TestData.McDonaldsReview.Id)) + .Returns(Task.FromResult(TestData.McDonaldsReview)); + var controller = new ReviewController(null, mockReviewQuery.Object, null, null); + + var result = await controller.GetAsync(TestData.WendysReview.Id); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task GetWithInvalidIdReturnsBadRequest() + { + var controller = new ReviewController(null, null, null, null); + + var result = await controller.GetAsync(0); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task GetListWithValidReviewerEmailReturnsReviews() + { + var mockReviewQuery = new Mock(); + mockReviewQuery.Setup(q => q.GetReviews(TestData.McDonaldsReview.ReviewerEmail)) + .Returns(Task.FromResult(new List { TestData.McDonaldsReview })); + var controller = new ReviewController(null, mockReviewQuery.Object, null, null); + + var result = await controller.GetListAsync(TestData.McDonaldsReview.ReviewerEmail); + + Assert.IsType(result.Result); + Assert.IsType>(((OkObjectResult)result.Result).Value); + var resultList = (List)((OkObjectResult) result.Result).Value; + Assert.Single(resultList); + } + + [Fact] + public async Task GetListWithReviewerEmailNoReviewsReturnsEmptyList() + { + var mockReviewQuery = new Mock(); + mockReviewQuery.Setup(q => q.GetReviews(TestData.McDonaldsReview.ReviewerEmail)) + .Returns(Task.FromResult(new List { TestData.McDonaldsReview })); + var controller = new ReviewController(null, mockReviewQuery.Object, null, null); + + var result = await controller.GetListAsync(TestData.WendysReview.ReviewerEmail); + + Assert.IsType(result.Result); + Assert.Null(((OkObjectResult)result.Result).Value); + } + + [Fact] + public async Task GetListWithInvalidReviewerEmailReturnsBadRequest() + { + var controller = new ReviewController(null, null, null, null); + + var result = await controller.GetListAsync(string.Empty); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task PostValidNewReviewReturnsCreated() + { + var mockValidator = new Mock(); + mockValidator.Setup(v => v.IsReviewValid(It.IsAny())).Returns(true); + var mockInsertReview = new Mock(); + var controller = new ReviewController(mockValidator.Object, null, mockInsertReview.Object, null); + + var result = await controller.PostAsync(TestData.WendysReview); + + Assert.IsType(result.Result); + mockInsertReview.Verify(i => i.Insert(It.IsAny()), + Times.Once); + } + + [Fact] + public async Task PostInvalidNewReviewReturnsCreated() + { + var mockValidator = new Mock(); + mockValidator.Setup(v => v.IsReviewValid(It.IsAny())).Returns(false); + var controller = new ReviewController(mockValidator.Object, null, null, null); + + var result = await controller.PostAsync(TestData.WendysReview); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task DeleteExistingReviewReturnsOk() + { + var mockDeleteReview = new Mock(); + mockDeleteReview.Setup(d => d.Delete(123)).Returns(Task.FromResult(1)); + var controller = new ReviewController(null, null, null, mockDeleteReview.Object); + + var result = await controller.DeleteAsync(123); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task DeleteMissingReviewReturnsNotFound() + { + var mockDeleteReview = new Mock(); + mockDeleteReview.Setup(d => d.Delete(123)).Returns(Task.FromResult(0)); + var controller = new ReviewController(null, null, null, mockDeleteReview.Object); + + var result = await controller.DeleteAsync(123); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task DeleteInvalidIdReturnsBadRequest() + { + var controller = new ReviewController(null, null, null, null); + + var result = await controller.DeleteAsync(-1); + + Assert.IsType(result.Result); + } + } +} \ No newline at end of file diff --git a/RestaurantReviews.Api.UnitTests/ControllerTests/TestData.cs b/RestaurantReviews.Api.UnitTests/ControllerTests/TestData.cs new file mode 100644 index 00000000..e21777a7 --- /dev/null +++ b/RestaurantReviews.Api.UnitTests/ControllerTests/TestData.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using RestaurantReviews.Api.Models; + +namespace RestaurantReviews.Api.UnitTests.ControllerTests +{ + public static class TestData + { + public static readonly Restaurant McDonalds = new Restaurant + { + Id = 123, + Name = "McDonald's", + Description = "The Golden Arches", + City = "Pittsburgh", + State = "PA" + }; + + public static readonly Restaurant Wendys = new Restaurant + { + Id = 456, + Name = "Wendy's", + Description = "Dave's Place", + City = "Pittsburgh", + State = "PA" + }; + + public static readonly List RestaurantTable = + new List { McDonalds, Wendys }; + + public static readonly Review McDonaldsReview = new Review + { + Id = 1234, + RestaurantId = McDonalds.Id, + ReviewerEmail = "jane@smith.xyz", + RatingStars = 4.5m, + Comments = "Great Nuggets!", + ReviewedOn = DateTimeOffset.Now + }; + + public static readonly Review WendysReview = new Review + { + Id = 5678, + RestaurantId = Wendys.Id, + ReviewerEmail = "fred@jones.xyz", + RatingStars = 3.2m, + Comments = "Frosty was yummy.", + ReviewedOn = DateTimeOffset.Now + }; + } +} \ No newline at end of file diff --git a/RestaurantReviews.Api.UnitTests/RestaurantReviews.Api.UnitTests.csproj b/RestaurantReviews.Api.UnitTests/RestaurantReviews.Api.UnitTests.csproj new file mode 100644 index 00000000..6ac52470 --- /dev/null +++ b/RestaurantReviews.Api.UnitTests/RestaurantReviews.Api.UnitTests.csproj @@ -0,0 +1,21 @@ + + + + netcoreapp2.2 + + false + + + + + + + + + + + + + + + diff --git a/RestaurantReviews.Api.UnitTests/ValidatorTests/RestaurantValidatorTests.cs b/RestaurantReviews.Api.UnitTests/ValidatorTests/RestaurantValidatorTests.cs new file mode 100644 index 00000000..7a764dd9 --- /dev/null +++ b/RestaurantReviews.Api.UnitTests/ValidatorTests/RestaurantValidatorTests.cs @@ -0,0 +1,157 @@ +using RestaurantReviews.Api.Models; +using Xunit; + +namespace RestaurantReviews.Api.UnitTests.ValidatorTests +{ + public class RestaurantValidatorTests + { + private const string ValidName = "McDonald's"; + private const string ValidDescription = "A place."; + private const string ValidCity = "Pittsburgh"; + private const string ValidState = "PA"; + private const string TooShortState = "P"; + private const string TooLongState = "PEN"; + + [Fact] + public void NoNameFailsValidation() + { + var validator = new RestaurantValidator(); + var restaurant = new Restaurant + { + Name = null, + Description = ValidDescription, + City = ValidCity, + State = ValidState + }; + + var result = validator.IsRestaurantValid(restaurant); + + Assert.False(result); + } + + [Fact] + public void NoDescriptionFailsValidation() + { + var validator = new RestaurantValidator(); + var restaurant = new Restaurant + { + Name = ValidName, + Description = null, + City = ValidCity, + State = ValidState + }; + + var result = validator.IsRestaurantValid(restaurant); + + Assert.False(result); + } + + [Fact] + public void NoCityFailsValidation() + { + var validator = new RestaurantValidator(); + var restaurant = new Restaurant + { + Name = ValidName, + Description = ValidDescription, + City = null, + State = ValidState + + }; + + var result = validator.IsRestaurantValid(restaurant); + + Assert.False(result); + } + + [Fact] + public void NoStateFailsValidation() + { + var validator = new RestaurantValidator(); + var restaurant = new Restaurant + { + Name = ValidName, + Description = ValidDescription, + City = ValidCity, + State = null + + }; + + var result = validator.IsRestaurantValid(restaurant); + + Assert.False(result); + } + + [Fact] + public void ShortStateFailsValidation() + { + var validator = new RestaurantValidator(); + var restaurant = new Restaurant + { + Name = ValidName, + Description = ValidDescription, + City = ValidCity, + State = TooShortState + + }; + + var result = validator.IsRestaurantValid(restaurant); + + Assert.False(result); + } + + [Fact] + public void LongStateFailsValidation() + { + var validator = new RestaurantValidator(); + var restaurant = new Restaurant + { + Name = ValidName, + Description = ValidDescription, + City = ValidCity, + State = TooLongState + + }; + + var result = validator.IsRestaurantValid(restaurant); + + Assert.False(result); + } + + [Fact] + public void NoAnythingFailsValidation() + { + var validator = new RestaurantValidator(); + var restaurant = new Restaurant + { + Name = null, + Description = null, + City = null, + State = null + + }; + + var result = validator.IsRestaurantValid(restaurant); + + Assert.False(result); + } + + [Fact] + public void AllGoodPassesValidation() + { + var validator = new RestaurantValidator(); + var restaurant = new Restaurant + { + Name = ValidName, + Description = ValidDescription, + City = ValidCity, + State = ValidState + + }; + + var result = validator.IsRestaurantValid(restaurant); + + Assert.True(result); + } + } +} \ No newline at end of file diff --git a/RestaurantReviews.Api.UnitTests/ValidatorTests/ReviewValidatorTests.cs b/RestaurantReviews.Api.UnitTests/ValidatorTests/ReviewValidatorTests.cs new file mode 100644 index 00000000..9d49b46f --- /dev/null +++ b/RestaurantReviews.Api.UnitTests/ValidatorTests/ReviewValidatorTests.cs @@ -0,0 +1,114 @@ +using System.Threading.Tasks; +using Moq; +using RestaurantReviews.Api.DataAccess; +using RestaurantReviews.Api.Models; +using Xunit; + +namespace RestaurantReviews.Api.UnitTests.ValidatorTests +{ + public class ReviewValidatorTests + { + [Fact] + public void NoReviewerEmailFailsValidation() + { + var validator = new ReviewValidator(null); + var review = new NewReview + { + ReviewerEmail = null + }; + + var result = validator.IsReviewValid(review); + + Assert.False(result); + } + + [Fact] + public void TooLowRatingFailsValidation() + { + var validator = new ReviewValidator(null); + var review = new NewReview + { + ReviewerEmail = "x@y.xyz", + RatingStars = -0.1m + }; + + var result = validator.IsReviewValid(review); + + Assert.False(result); + } + + [Fact] + public void TooHighRatingFailsValidation() + { + var validator = new ReviewValidator(null); + var review = new NewReview + { + ReviewerEmail = "x@y.xyz", + RatingStars = 5.1m + }; + + var result = validator.IsReviewValid(review); + + Assert.False(result); + } + + [Fact] + public void RestaurantNotFoundFailsValidation() + { + var restaurantQueryMock = new Mock(); + restaurantQueryMock.Setup(q => q.GetRestaurant(999)). + Returns(Task.FromResult(null)); + var validator = new ReviewValidator(restaurantQueryMock.Object); + var review = new NewReview + { + ReviewerEmail = "x@y.xyz", + RatingStars = 5.1m, + RestaurantId = 999 + }; + + var result = validator.IsReviewValid(review); + + Assert.False(result); + } + + [Fact] + public void GoodReviewPassesValidation() + { + var restaurantMock = new Mock(); + var restaurantQueryMock = new Mock(); + restaurantQueryMock.Setup(q => q.GetRestaurant(1)). + Returns(Task.FromResult(restaurantMock.Object)); + var validator = new ReviewValidator(restaurantQueryMock.Object); + var review = new NewReview + { + ReviewerEmail = "x@y.xyz", + RatingStars = 5.0m, + RestaurantId = 1 + }; + + var result = validator.IsReviewValid(review); + + Assert.True(result); + } + + [Fact] + public void ReallyBadReviewPassesValidation() + { + var restaurantMock = new Mock(); + var restaurantQueryMock = new Mock(); + restaurantQueryMock.Setup(q => q.GetRestaurant(1)). + Returns(Task.FromResult(restaurantMock.Object)); + var validator = new ReviewValidator(restaurantQueryMock.Object); + var review = new NewReview + { + ReviewerEmail = "x@y.xyz", + RatingStars = 0.0m, + RestaurantId = 1 + }; + + var result = validator.IsReviewValid(review); + + Assert.True(result); + } + } +} \ No newline at end of file diff --git a/RestaurantReviews.Api/Controllers/RestaurantController.cs b/RestaurantReviews.Api/Controllers/RestaurantController.cs new file mode 100644 index 00000000..0407d910 --- /dev/null +++ b/RestaurantReviews.Api/Controllers/RestaurantController.cs @@ -0,0 +1,104 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using RestaurantReviews.Api.DataAccess; +using RestaurantReviews.Api.Models; + +namespace RestaurantReviews.Api.Controllers +{ + [Produces("application/json")] + [Route("api/[controller]")] + public class RestaurantController : ControllerBase + { + private readonly IRestaurantValidator _restaurantValidator; + private readonly IRestaurantQuery _restaurantQuery; + private readonly IInsertRestaurant _insertRestaurant; + + public RestaurantController(IRestaurantValidator restaurantValidator, + IRestaurantQuery restaurantQuery, + IInsertRestaurant insertRestaurant) + { + _restaurantValidator = restaurantValidator; + _restaurantQuery = restaurantQuery; + _insertRestaurant = insertRestaurant; + } + + /// + /// Returns a list of restaurants for the given city and state. If not provided, + /// gives the entire list of restaurants we have reviews for. + /// + /// The City where the Restaurant is located + /// The State where the Restaurant is located + /// + /// A collection of Restaurant objects matching the given city and state. + /// + [HttpGet] + [ProducesResponseType(200)] + [ProducesResponseType(400)] + public async Task>> GetListAsync(string city=null, + string state=null) + { + if ((city == null || state == null ) && city != state) + { + return BadRequest("If city or state is provided, both must be given."); + } + + return Ok(await _restaurantQuery.GetRestaurants(city, state)); + } + + /// + /// Returns the restaurant at the given internal identifier. + /// + /// The internal identifier of the restaurant. + /// The Restaurant + [HttpGet] + [Route("{id}")] + [ProducesResponseType(200)] + [ProducesResponseType(400)] + [ProducesResponseType(404)] + public async Task> GetAsync(long id) + { + if (id <= 0) + { + return BadRequest("id must be greater than 0"); + } + + var restaurant = await _restaurantQuery.GetRestaurant(id); + if (restaurant == null) + { + return NotFound(); + } + + return Ok(restaurant); + } + + /// + /// Adds a new restaurant, if it does not exist. + /// + /// The restaurant to add. + /// The restaurant, with the internal Id added. + [HttpPost] + [ProducesResponseType(201)] + [ProducesResponseType(400)] + [ProducesResponseType(409)] + public async Task> PostAsync(NewRestaurant restaurant) + { + if (!_restaurantValidator.IsRestaurantValid(restaurant)) + { + return BadRequest("Restaurants require a valid Name, Description, City, and State."); + } + + var existing = await _restaurantQuery.GetRestaurant(restaurant.Name, + restaurant.City, restaurant.State); + if (existing != null) + { + return Conflict( + $"A restaurant called {existing.Name} in {existing.City}, {existing.State} already exists as Id={existing.Id}."); + } + + var id = await _insertRestaurant.Insert(restaurant); + + return Created(nameof(GetAsync), Restaurant.FromNew(id, restaurant)); + } + } +} \ No newline at end of file diff --git a/RestaurantReviews.Api/Controllers/ReviewController.cs b/RestaurantReviews.Api/Controllers/ReviewController.cs new file mode 100644 index 00000000..ff7b2b7e --- /dev/null +++ b/RestaurantReviews.Api/Controllers/ReviewController.cs @@ -0,0 +1,114 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using RestaurantReviews.Api.DataAccess; +using RestaurantReviews.Api.Models; + +namespace RestaurantReviews.Api.Controllers +{ + [Produces("application/json")] + [Route("api/[controller]")] + public class ReviewController : ControllerBase + { + private readonly IReviewValidator _reviewValidator; + private readonly IReviewQuery _reviewQuery; + private readonly IInsertReview _insertReview; + private readonly IDeleteReview _deleteReview; + + public ReviewController(IReviewValidator reviewValidator, + IReviewQuery reviewQuery, + IInsertReview insertReview, + IDeleteReview deleteReview) + { + _reviewValidator = reviewValidator; + _reviewQuery = reviewQuery; + _insertReview = insertReview; + _deleteReview = deleteReview; + } + + /// + /// Returns the review at the given internal identifier. + /// + /// The internal identifier of the review. + /// The Review + [HttpGet] + [Route("{id}")] + [ProducesResponseType(200)] + [ProducesResponseType(400)] + [ProducesResponseType(404)] + public async Task> GetAsync(long id) + { + if (id <= 0) + { + return BadRequest("id must be greater than 0"); + } + + var review = await _reviewQuery.GetReview(id); + if (review == null) + { + return NotFound(); + } + + return Ok(review); + } + + /// + /// Returns all of the reviews for a particular user. + /// + /// The email address of the user who's reviews are requested. + /// A collection of Reviews. + [HttpGet] + [ProducesResponseType(200)] + [ProducesResponseType(400)] + public async Task>> GetListAsync(string reviewerEmail) + { + if (string.IsNullOrWhiteSpace(reviewerEmail)) + { + return BadRequest("ReviewerEmail is required."); + } + + return Ok(await _reviewQuery.GetReviews(reviewerEmail)); + } + + /// + /// Adds a review for a restaurant. + /// + /// A review to add to the system. + /// The restaurant, with the internal Id added. + [HttpPost] + [ProducesResponseType(201)] + [ProducesResponseType(400)] + public async Task> PostAsync(NewReview review) + { + if (!_reviewValidator.IsReviewValid(review)) + { + return BadRequest("Reviews require a valid restaurant, reviewer email, and a rating between 0 and 5."); + } + + var id = await _insertReview.Insert(review); + + return Created(nameof(GetAsync), id != 0); + } + + + [HttpDelete] + [ProducesResponseType(200)] + [ProducesResponseType(400)] + [ProducesResponseType(404)] + public async Task> DeleteAsync(long reviewId) + { + if (reviewId <= 0) + { + return BadRequest("ReviewId is required."); + } + + var rowsAffected = await _deleteReview.Delete(reviewId); + if (rowsAffected != 1) + { + return NotFound(false); + } + + return Ok(true); + } + } +} \ No newline at end of file diff --git a/RestaurantReviews.Api/DataAccess/DeleteReview.cs b/RestaurantReviews.Api/DataAccess/DeleteReview.cs new file mode 100644 index 00000000..f2b31eb0 --- /dev/null +++ b/RestaurantReviews.Api/DataAccess/DeleteReview.cs @@ -0,0 +1,23 @@ +using System.Threading.Tasks; +using Dapper; + +namespace RestaurantReviews.Api.DataAccess +{ + public class DeleteReview : IDeleteReview + { + private const string BaseQuery = "DELETE FROM [dbo].[Review] WHERE Id=@Id"; + + private readonly IUnitOfWork _unitOfWork; + + public DeleteReview(IUnitOfWork unitOfWork) + { + _unitOfWork = unitOfWork; + } + + public Task Delete(long id) + { + var rowCount = _unitOfWork.Connection.ExecuteAsync(BaseQuery, new {Id = id}); + return rowCount; + } + } +} \ No newline at end of file diff --git a/RestaurantReviews.Api/DataAccess/IDeleteReview.cs b/RestaurantReviews.Api/DataAccess/IDeleteReview.cs new file mode 100644 index 00000000..013065a2 --- /dev/null +++ b/RestaurantReviews.Api/DataAccess/IDeleteReview.cs @@ -0,0 +1,9 @@ +using System.Threading.Tasks; + +namespace RestaurantReviews.Api.DataAccess +{ + public interface IDeleteReview + { + Task Delete(long id); + } +} \ No newline at end of file diff --git a/RestaurantReviews.Api/DataAccess/IInsertRestaurant.cs b/RestaurantReviews.Api/DataAccess/IInsertRestaurant.cs new file mode 100644 index 00000000..4c6330d0 --- /dev/null +++ b/RestaurantReviews.Api/DataAccess/IInsertRestaurant.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; +using RestaurantReviews.Api.Models; + +namespace RestaurantReviews.Api.DataAccess +{ + public interface IInsertRestaurant + { + Task Insert(NewRestaurant restaurant); + } +} \ No newline at end of file diff --git a/RestaurantReviews.Api/DataAccess/IInsertReview.cs b/RestaurantReviews.Api/DataAccess/IInsertReview.cs new file mode 100644 index 00000000..e3e9427d --- /dev/null +++ b/RestaurantReviews.Api/DataAccess/IInsertReview.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; +using RestaurantReviews.Api.Models; + +namespace RestaurantReviews.Api.DataAccess +{ + public interface IInsertReview + { + Task Insert(NewReview review); + } +} \ No newline at end of file diff --git a/RestaurantReviews.Api/DataAccess/IRestaurantQuery.cs b/RestaurantReviews.Api/DataAccess/IRestaurantQuery.cs new file mode 100644 index 00000000..6509df80 --- /dev/null +++ b/RestaurantReviews.Api/DataAccess/IRestaurantQuery.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using RestaurantReviews.Api.Models; + +namespace RestaurantReviews.Api.DataAccess +{ + public interface IRestaurantQuery + { + Task> GetRestaurants(string city=null, string state=null); + + Task GetRestaurant(long id); + + Task GetRestaurant(string name, string city, string state); + } +} \ No newline at end of file diff --git a/RestaurantReviews.Api/DataAccess/IReviewQuery.cs b/RestaurantReviews.Api/DataAccess/IReviewQuery.cs new file mode 100644 index 00000000..448fc9a2 --- /dev/null +++ b/RestaurantReviews.Api/DataAccess/IReviewQuery.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using RestaurantReviews.Api.Models; + +namespace RestaurantReviews.Api.DataAccess +{ + public interface IReviewQuery + { + Task GetReview(long id); + + Task> GetReviews(string reviewerEmail); + } +} \ No newline at end of file diff --git a/RestaurantReviews.Api/DataAccess/IUnitOfWork.cs b/RestaurantReviews.Api/DataAccess/IUnitOfWork.cs new file mode 100644 index 00000000..d6b585ee --- /dev/null +++ b/RestaurantReviews.Api/DataAccess/IUnitOfWork.cs @@ -0,0 +1,9 @@ +using System.Data; + +namespace RestaurantReviews.Api.DataAccess +{ + public interface IUnitOfWork + { + IDbConnection Connection { get; } + } +} \ No newline at end of file diff --git a/RestaurantReviews.Api/DataAccess/InsertRestaurant.cs b/RestaurantReviews.Api/DataAccess/InsertRestaurant.cs new file mode 100644 index 00000000..c64129ae --- /dev/null +++ b/RestaurantReviews.Api/DataAccess/InsertRestaurant.cs @@ -0,0 +1,45 @@ +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using RestaurantReviews.Api.Models; + +namespace RestaurantReviews.Api.DataAccess +{ + public class InsertRestaurant : IInsertRestaurant + { + private const string BaseQuery = @" + INSERT INTO [dbo].[Restaurant] ( + [Name] + ,[Description] + ,[City] + ,[State]) + VALUES ( + @Name, + @Description, + @City, + @State) + + SELECT CAST(SCOPE_IDENTITY() as int)"; + + private readonly IUnitOfWork _unitOfWork; + + public InsertRestaurant(IUnitOfWork unitOfWork) + { + _unitOfWork = unitOfWork; + } + + public async Task Insert(NewRestaurant restaurant) + { + var result = await _unitOfWork.Connection.QueryAsync( + BaseQuery, + new + { + restaurant.Name, + restaurant.Description, + restaurant.City, + restaurant.State + }); + return result.Single(); + } + } +} \ No newline at end of file diff --git a/RestaurantReviews.Api/DataAccess/InsertReview.cs b/RestaurantReviews.Api/DataAccess/InsertReview.cs new file mode 100644 index 00000000..9b34ce66 --- /dev/null +++ b/RestaurantReviews.Api/DataAccess/InsertReview.cs @@ -0,0 +1,47 @@ +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using RestaurantReviews.Api.Models; + +namespace RestaurantReviews.Api.DataAccess +{ + public class InsertReview : IInsertReview + { + private const string BaseQuery = @" + INSERT INTO [dbo].[Review] ( + [RestaurantId] + ,[ReviewerEmail] + ,[RatingStars] + ,[Comments] + ,[ReviewedOn]) + VALUES ( + @RestaurantId, + @ReviewerEmail, + @RatingStars, + @Comments, + SYSDATETIMEOFFSET()) + + SELECT CAST(SCOPE_IDENTITY() as int)"; + + private readonly IUnitOfWork _unitOfWork; + + public InsertReview(IUnitOfWork unitOfWork) + { + _unitOfWork = unitOfWork; + } + + public async Task Insert(NewReview review) + { + var result = await _unitOfWork.Connection.QueryAsync( + BaseQuery, + new + { + review.RestaurantId, + review.ReviewerEmail, + review.RatingStars, + review.Comments + }); + return result.Single(); + } + } +} \ No newline at end of file diff --git a/RestaurantReviews.Api/DataAccess/RestaurantQuery.cs b/RestaurantReviews.Api/DataAccess/RestaurantQuery.cs new file mode 100644 index 00000000..fb9d3ec6 --- /dev/null +++ b/RestaurantReviews.Api/DataAccess/RestaurantQuery.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using RestaurantReviews.Api.Models; + +namespace RestaurantReviews.Api.DataAccess +{ + public class RestaurantQuery : IRestaurantQuery + { + private const string BaseQuery = "SELECT * FROM dbo.Restaurant"; + + private readonly IUnitOfWork _unitOfWork; + + public RestaurantQuery(IUnitOfWork unitOfWork) + { + _unitOfWork = unitOfWork; + } + + public async Task> GetRestaurants(string city=null, string state=null) + { + string query; + object parameters; + + if (city == null) + { + query = BaseQuery; + parameters = null; + } + else + { + query = BaseQuery + " WHERE City = @City AND State = @State"; + parameters = new { City = city, State = state }; + } + + var result = await _unitOfWork.Connection.QueryAsync(query, parameters); + return result.ToList(); + } + + public async Task GetRestaurant(long id) + { + var result = await _unitOfWork.Connection.QueryAsync( + BaseQuery + " WHERE Id=@Id", + new { Id = id }); + return result.FirstOrDefault(); + } + + public async Task GetRestaurant(string name, string city, string state) + { + var result = await _unitOfWork.Connection.QueryAsync( + BaseQuery + " WHERE Name=@Name AND City = @City AND State = @State", + new { Name = name, City = city, State = state }); + var resultList = result.ToList(); + + return resultList.Count() == 1 ? resultList.First() : null; + } + } +} \ No newline at end of file diff --git a/RestaurantReviews.Api/DataAccess/ReviewQuery.cs b/RestaurantReviews.Api/DataAccess/ReviewQuery.cs new file mode 100644 index 00000000..d29c8a90 --- /dev/null +++ b/RestaurantReviews.Api/DataAccess/ReviewQuery.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using RestaurantReviews.Api.Models; + +namespace RestaurantReviews.Api.DataAccess +{ + public class ReviewQuery : IReviewQuery + { + private const string BaseQuery = "SELECT * FROM dbo.Review"; + + private readonly IUnitOfWork _unitOfWork; + + public ReviewQuery(IUnitOfWork unitOfWork) + { + _unitOfWork = unitOfWork; + } + + public async Task GetReview(long id) + { + var result = await _unitOfWork.Connection.QueryAsync( + BaseQuery + " WHERE Id=@Id", + new { Id = id }); + return result.FirstOrDefault(); + } + + public async Task> GetReviews(string reviewerEmail) + { + var result = await _unitOfWork.Connection.QueryAsync( + BaseQuery + " WHERE ReviewerEmail=@ReviewerEmail", + new { ReviewerEmail = reviewerEmail }); + return result.ToList(); + } + } +} \ No newline at end of file diff --git a/RestaurantReviews.Api/DataAccess/UnitOfWork.cs b/RestaurantReviews.Api/DataAccess/UnitOfWork.cs new file mode 100644 index 00000000..b65791ae --- /dev/null +++ b/RestaurantReviews.Api/DataAccess/UnitOfWork.cs @@ -0,0 +1,30 @@ +using System; +using System.Data; +using System.Data.SqlClient; +using Microsoft.Extensions.Configuration; + +namespace RestaurantReviews.Api.DataAccess +{ + public class UnitOfWork : IUnitOfWork, IDisposable + { + private readonly IConfiguration _configuration; + private IDbConnection _connection; + + public UnitOfWork(IConfiguration configuration) + { + _configuration = configuration; + } + + private IDbConnection GetOpenConnection() + { + return _connection ?? (_connection = new SqlConnection(_configuration.GetConnectionString("RestaurantDb"))); + } + + public IDbConnection Connection => GetOpenConnection(); + + public void Dispose() + { + _connection?.Dispose(); + } + } +} \ No newline at end of file diff --git a/RestaurantReviews.Api/MigrationHandler.cs b/RestaurantReviews.Api/MigrationHandler.cs new file mode 100644 index 00000000..882ec7eb --- /dev/null +++ b/RestaurantReviews.Api/MigrationHandler.cs @@ -0,0 +1,50 @@ +using System; +using System.IO; +using FluentMigrator.Runner; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace RestaurantReviews.Api +{ + public class MigrationHandler + { + public static void RunMigrations() + { + var serviceProvider = CreateServices(); + + using (var scope = serviceProvider.CreateScope()) + { + UpdateDatabase(scope.ServiceProvider); + } + } + + private static string GetConnectionString() + { + var root = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: true) + .Build(); + + return root.GetConnectionString("RestaurantDb"); + } + + private static IServiceProvider CreateServices() + { + return new ServiceCollection() + .AddFluentMigratorCore() + .ConfigureRunner(rb => rb + .AddSqlServer() + .WithGlobalConnectionString(GetConnectionString()) + .ScanIn(typeof(RestaurantReviews.Api.MigrationHandler).Assembly).For.Migrations()) + .AddLogging(lb => lb.AddFluentMigratorConsole()) + .BuildServiceProvider(false); + } + + private static void UpdateDatabase(IServiceProvider serviceProvider) + { + var runner = serviceProvider.GetRequiredService(); + + runner.MigrateUp(); + } + } +} diff --git a/RestaurantReviews.Api/Migrations/201901261145_AddRestaurantTable.cs b/RestaurantReviews.Api/Migrations/201901261145_AddRestaurantTable.cs new file mode 100644 index 00000000..62fbf5e4 --- /dev/null +++ b/RestaurantReviews.Api/Migrations/201901261145_AddRestaurantTable.cs @@ -0,0 +1,23 @@ +using FluentMigrator; + +namespace RestaurantReviews.Api.Migrations +{ + [Migration(201901261145)] + public class AddRestaurantTable : Migration + { + public override void Up() + { + Create.Table("Restaurant") + .WithColumn("Id").AsInt64().PrimaryKey().Identity() + .WithColumn("Name").AsString(200) + .WithColumn("Description").AsString(2000) + .WithColumn("City").AsString(50) + .WithColumn("State").AsString(2); + } + + public override void Down() + { + Delete.Table("Restaurant"); + } + } +} \ No newline at end of file diff --git a/RestaurantReviews.Api/Migrations/201901261530_AddReviewTable.cs b/RestaurantReviews.Api/Migrations/201901261530_AddReviewTable.cs new file mode 100644 index 00000000..a9e42efa --- /dev/null +++ b/RestaurantReviews.Api/Migrations/201901261530_AddReviewTable.cs @@ -0,0 +1,28 @@ +using FluentMigrator; + +namespace RestaurantReviews.Api.Migrations +{ + [Migration(201901261530)] + public class AddReviewTable : Migration + { + public override void Up() + { + Create.Table("Review") + .WithColumn("Id").AsInt64().PrimaryKey().Identity() + .WithColumn("RestaurantId").AsInt64() + .WithColumn("ReviewerEmail").AsString(300) + .WithColumn("RatingStars").AsDecimal(3,1) + .WithColumn("Comments").AsString(4000) + .WithColumn("ReviewedOn").AsDateTimeOffset(); + + Create.ForeignKey("fk_Review_RestaurantId_Restaurant_Id") + .FromTable("Review").ForeignColumn("RestaurantId") + .ToTable("Restaurant").PrimaryColumn("Id"); + } + + public override void Down() + { + Delete.Table("Review"); + } + } +} \ No newline at end of file diff --git a/RestaurantReviews.Api/Models/IRestaurantValidator.cs b/RestaurantReviews.Api/Models/IRestaurantValidator.cs new file mode 100644 index 00000000..06aad1f4 --- /dev/null +++ b/RestaurantReviews.Api/Models/IRestaurantValidator.cs @@ -0,0 +1,7 @@ +namespace RestaurantReviews.Api.Models +{ + public interface IRestaurantValidator + { + bool IsRestaurantValid(NewRestaurant restaurant); + } +} \ No newline at end of file diff --git a/RestaurantReviews.Api/Models/IReviewValidator.cs b/RestaurantReviews.Api/Models/IReviewValidator.cs new file mode 100644 index 00000000..6ca3cd1c --- /dev/null +++ b/RestaurantReviews.Api/Models/IReviewValidator.cs @@ -0,0 +1,7 @@ +namespace RestaurantReviews.Api.Models +{ + public interface IReviewValidator + { + bool IsReviewValid(NewReview review); + } +} \ No newline at end of file diff --git a/RestaurantReviews.Api/Models/NewRestaurant.cs b/RestaurantReviews.Api/Models/NewRestaurant.cs new file mode 100644 index 00000000..05313d45 --- /dev/null +++ b/RestaurantReviews.Api/Models/NewRestaurant.cs @@ -0,0 +1,28 @@ +namespace RestaurantReviews.Api.Models +{ + /// + /// A new Restaurant for our review system. + /// + public class NewRestaurant + { + /// + /// The Name of the restaurant + /// + public string Name { get; set; } + + /// + /// A Description of the restaurant. + /// + public string Description { get; set;} + + /// + /// The City where the restaurant is located. + /// + public string City { get; set; } + + /// + /// The State where the restaurant is located. + /// + public string State { get; set; } + } +} \ No newline at end of file diff --git a/RestaurantReviews.Api/Models/NewReview.cs b/RestaurantReviews.Api/Models/NewReview.cs new file mode 100644 index 00000000..6bdb9a3c --- /dev/null +++ b/RestaurantReviews.Api/Models/NewReview.cs @@ -0,0 +1,28 @@ +namespace RestaurantReviews.Api.Models +{ + /// + /// A new restaurant review. + /// + public class NewReview + { + /// + /// The identifier for the restaurant this review is about. + /// + public long RestaurantId { get; set; } + + /// + /// The email address of the user who provided this review. + /// + public string ReviewerEmail { get; set; } + + /// + /// The number of stars the reviewer gave the restaurant. + /// + public decimal RatingStars { get; set; } + + /// + /// The reviewers comments about the restaurant. + /// + public string Comments { get; set; } + } +} \ No newline at end of file diff --git a/RestaurantReviews.Api/Models/Restaurant.cs b/RestaurantReviews.Api/Models/Restaurant.cs new file mode 100644 index 00000000..ea6bd367 --- /dev/null +++ b/RestaurantReviews.Api/Models/Restaurant.cs @@ -0,0 +1,29 @@ + +using Remotion.Linq.Clauses; + +namespace RestaurantReviews.Api.Models +{ + /// + /// + /// A Restaurant you can see the reviews for, if we have any. + /// + public class Restaurant : NewRestaurant + { + /// + /// The internal identifier for this Restaurant. + /// + public long Id { get; set; } + + public static Restaurant FromNew(long newId, NewRestaurant newRestaurant) + { + return new Restaurant + { + Id = newId, + Name = newRestaurant.Name, + Description = newRestaurant.Description, + City = newRestaurant.City, + State = newRestaurant.State + }; + } + } +} \ No newline at end of file diff --git a/RestaurantReviews.Api/Models/RestaurantValidator.cs b/RestaurantReviews.Api/Models/RestaurantValidator.cs new file mode 100644 index 00000000..83e67ff4 --- /dev/null +++ b/RestaurantReviews.Api/Models/RestaurantValidator.cs @@ -0,0 +1,13 @@ +namespace RestaurantReviews.Api.Models +{ + public class RestaurantValidator : IRestaurantValidator + { + public bool IsRestaurantValid(NewRestaurant restaurant) + { + return !string.IsNullOrWhiteSpace(restaurant.Name) && + !string.IsNullOrWhiteSpace(restaurant.City) && + restaurant.State?.Length == 2 && + !string.IsNullOrWhiteSpace(restaurant.Description); + } + } +} \ No newline at end of file diff --git a/RestaurantReviews.Api/Models/Review.cs b/RestaurantReviews.Api/Models/Review.cs new file mode 100644 index 00000000..a8ce195d --- /dev/null +++ b/RestaurantReviews.Api/Models/Review.cs @@ -0,0 +1,35 @@ +using System; + +namespace RestaurantReviews.Api.Models +{ + /// + /// + /// A review of a restaurant. + /// + public class Review : NewReview + { + /// + /// The internal identifier for this Review. + /// + public long Id { get; set; } + + /// + /// When the review was filed. + /// + public DateTimeOffset ReviewedOn { get; set; } + + public static Review FromNew(long newId, + DateTimeOffset reviewedOn, NewReview newReview) + { + return new Review + { + Id = newId, + RestaurantId = newReview.RestaurantId, + ReviewerEmail = newReview.ReviewerEmail, + RatingStars = newReview.RatingStars, + Comments = newReview.Comments, + ReviewedOn = reviewedOn + }; + } + } +} \ No newline at end of file diff --git a/RestaurantReviews.Api/Models/ReviewValidator.cs b/RestaurantReviews.Api/Models/ReviewValidator.cs new file mode 100644 index 00000000..2246b28c --- /dev/null +++ b/RestaurantReviews.Api/Models/ReviewValidator.cs @@ -0,0 +1,35 @@ +using RestaurantReviews.Api.DataAccess; + +namespace RestaurantReviews.Api.Models +{ + public class ReviewValidator : IReviewValidator + { + private readonly IRestaurantQuery _restaurantQuery; + + public ReviewValidator(IRestaurantQuery restaurantQuery) + { + _restaurantQuery = restaurantQuery; + } + + public bool IsReviewValid(NewReview review) + { + if (string.IsNullOrWhiteSpace(review.ReviewerEmail)) + { + return false; + } + + if (review.RatingStars < 0 || review.RatingStars > 5) + { + return false; + } + + var restaurant = _restaurantQuery.GetRestaurant(review.RestaurantId).Result; + if (restaurant == null) + { + return false; + } + + return true; + } + } +} \ No newline at end of file diff --git a/RestaurantReviews.Api/Program.cs b/RestaurantReviews.Api/Program.cs new file mode 100644 index 00000000..c793bac0 --- /dev/null +++ b/RestaurantReviews.Api/Program.cs @@ -0,0 +1,25 @@ +using System.Linq; +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Hosting; + +namespace RestaurantReviews.Api +{ + public class Program + { + public static void Main(string[] args) + { + if (args != null && args.Any() && args[0] == "migrate") + { + MigrationHandler.RunMigrations(); + } + else + { + CreateWebHostBuilder(args).Build().Run(); + } + } + + private 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..e1f4a2dd --- /dev/null +++ b/RestaurantReviews.Api/Properties/launchSettings.json @@ -0,0 +1,27 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:45290", + "sslPort": 44342 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "RestauranyReviews.Api": { + "commandName": "Project", + "launchBrowser": true, + "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..8465f6c5 --- /dev/null +++ b/RestaurantReviews.Api/RestaurantReviews.Api.csproj @@ -0,0 +1,22 @@ + + + + netcoreapp2.2 + + + + true + $(NoWarn);1591 + + + + + + + + + + + + + diff --git a/RestaurantReviews.Api/Startup.cs b/RestaurantReviews.Api/Startup.cs new file mode 100644 index 00000000..c201327c --- /dev/null +++ b/RestaurantReviews.Api/Startup.cs @@ -0,0 +1,63 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +//using NJsonSchema; +//using NSwag.AspNetCore; +using RestaurantReviews.Api.DataAccess; +using RestaurantReviews.Api.Models; + +namespace RestaurantReviews.Api +{ + public class Startup + { + public void ConfigureServices(IServiceCollection services) + { + services.AddScoped(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1); + + services.AddSwaggerDocument(config => + { + config.PostProcess = document => + { + document.Info.Version = "v1"; + document.Info.Title = "Restaurant Reviews"; + document.Info.Description = "A simple API for managing restaurant reviews"; + document.Info.TermsOfService = "None"; + document.Info.Contact = new NSwag.SwaggerContact + { + Name = "Eric Kepes", + Email = "eric@kepes.net", + Url = "https://github.com/ekepes" + }; + document.Info.License = new NSwag.SwaggerLicense + { + Name = "Use under LICX", + Url = "https://example.com/license" + }; + }; + }); + } + + public void Configure(IApplicationBuilder app, IHostingEnvironment env) + { + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + + app.UseSwagger(); + app.UseSwaggerUi3(); + + app.UseMvc(); + } + } +} diff --git a/RestaurantReviews.Api/appsettings.json b/RestaurantReviews.Api/appsettings.json new file mode 100644 index 00000000..c9059b9a --- /dev/null +++ b/RestaurantReviews.Api/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + }, + "ConnectionStrings": { + "RestaurantDb": "Server=.;Database=RestaurantDB;User Id=RestaurantService;Password=MyReallyBadPassword1;" + } + } \ No newline at end of file diff --git a/RestaurantReviews.sln b/RestaurantReviews.sln new file mode 100644 index 00000000..3fc5ea0a --- /dev/null +++ b/RestaurantReviews.sln @@ -0,0 +1,48 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 15 +VisualStudioVersion = 15.0.26124.0 +MinimumVisualStudioVersion = 15.0.26124.0 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RestaurantReviews.Api", "RestaurantReviews.Api\RestaurantReviews.Api.csproj", "{BCC56FD0-66B5-453E-B241-13A4CACB1CFC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RestaurantReviews.Api.UnitTests", "RestaurantReviews.Api.UnitTests\RestaurantReviews.Api.UnitTests.csproj", "{5D918772-04EA-418C-A894-ED0C716B4178}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {BCC56FD0-66B5-453E-B241-13A4CACB1CFC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BCC56FD0-66B5-453E-B241-13A4CACB1CFC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BCC56FD0-66B5-453E-B241-13A4CACB1CFC}.Debug|x64.ActiveCfg = Debug|Any CPU + {BCC56FD0-66B5-453E-B241-13A4CACB1CFC}.Debug|x64.Build.0 = Debug|Any CPU + {BCC56FD0-66B5-453E-B241-13A4CACB1CFC}.Debug|x86.ActiveCfg = Debug|Any CPU + {BCC56FD0-66B5-453E-B241-13A4CACB1CFC}.Debug|x86.Build.0 = Debug|Any CPU + {BCC56FD0-66B5-453E-B241-13A4CACB1CFC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BCC56FD0-66B5-453E-B241-13A4CACB1CFC}.Release|Any CPU.Build.0 = Release|Any CPU + {BCC56FD0-66B5-453E-B241-13A4CACB1CFC}.Release|x64.ActiveCfg = Release|Any CPU + {BCC56FD0-66B5-453E-B241-13A4CACB1CFC}.Release|x64.Build.0 = Release|Any CPU + {BCC56FD0-66B5-453E-B241-13A4CACB1CFC}.Release|x86.ActiveCfg = Release|Any CPU + {BCC56FD0-66B5-453E-B241-13A4CACB1CFC}.Release|x86.Build.0 = Release|Any CPU + {5D918772-04EA-418C-A894-ED0C716B4178}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5D918772-04EA-418C-A894-ED0C716B4178}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5D918772-04EA-418C-A894-ED0C716B4178}.Debug|x64.ActiveCfg = Debug|Any CPU + {5D918772-04EA-418C-A894-ED0C716B4178}.Debug|x64.Build.0 = Debug|Any CPU + {5D918772-04EA-418C-A894-ED0C716B4178}.Debug|x86.ActiveCfg = Debug|Any CPU + {5D918772-04EA-418C-A894-ED0C716B4178}.Debug|x86.Build.0 = Debug|Any CPU + {5D918772-04EA-418C-A894-ED0C716B4178}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5D918772-04EA-418C-A894-ED0C716B4178}.Release|Any CPU.Build.0 = Release|Any CPU + {5D918772-04EA-418C-A894-ED0C716B4178}.Release|x64.ActiveCfg = Release|Any CPU + {5D918772-04EA-418C-A894-ED0C716B4178}.Release|x64.Build.0 = Release|Any CPU + {5D918772-04EA-418C-A894-ED0C716B4178}.Release|x86.ActiveCfg = Release|Any CPU + {5D918772-04EA-418C-A894-ED0C716B4178}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/SUBMISSION-NOTES.md b/SUBMISSION-NOTES.md new file mode 100644 index 00000000..32652887 --- /dev/null +++ b/SUBMISSION-NOTES.md @@ -0,0 +1,16 @@ +Author: Eric Kepes (eric@kepes.net) + +I built this using .Net Core on a Mac. To make it a bit easier, I wrote some bash shell scripts to set things up and run the service: + +* startsql.sh - starts up SQL Server in a Docker Container and creates the initial database and user +* stopsql.sh - tears down the SQL Server container +* migrate.sh - runs the database migration scripts, which are embedded in the application via FluentMigrator +* run.sh - just a shortcut to start the app + +The migration process deserves a little more discussion. The migration is run if the application is started with the command line parameter "migrate". This is in keeping in line with the principles of a 12-factor app, which I try to follow as much as makes sense. + +None of the logins for SQL Server are secure - they are kept in plain text right in the repository. If this were a production app, I would not do it that way, but given there is no real risk, I left them there to facilitate easy running of the service by anyone reviewing it. + +If you run the application and browser to the app url http://localhost:5000/swagger, you will be presented with a Swagger UI that you can use to explore the API. This is provided by NSwag. + +There is no validation of users. Because it is not specified how to handle user accounts, I keep it simple and ignored the concept. Users are just email addresses. \ No newline at end of file diff --git a/migrate.sh b/migrate.sh new file mode 100755 index 00000000..aac86cba --- /dev/null +++ b/migrate.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +cd ./RestaurantReviews.Api + +dotnet run -- migrate + +cd .. \ No newline at end of file diff --git a/run.sh b/run.sh new file mode 100755 index 00000000..31efe80a --- /dev/null +++ b/run.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +dotnet run --project ./RestaurantReviews.Api/RestaurantReviews.Api.csproj \ No newline at end of file diff --git a/startsql.sh b/startsql.sh new file mode 100755 index 00000000..1389c698 --- /dev/null +++ b/startsql.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +# this is a throwaway password - if this were something more secure, +# we would not check this in or use an environment variable +docker run -e 'ACCEPT_EULA=Y' -e 'SA_PASSWORD=5g5R%pY!G8kt' --name restaurant-server -p 1433:1433 \ + -d microsoft/mssql-server-linux:2017-latest + +docker exec -it restaurant-server /opt/mssql-tools/bin/sqlcmd \ + -S localhost -U SA -P '5g5R%pY!G8kt' \ + -Q 'CREATE DATABASE RestaurantDB' + +docker exec -it restaurant-server /opt/mssql-tools/bin/sqlcmd \ + -S localhost -U SA -P '5g5R%pY!G8kt' \ + -Q 'CREATE LOGIN RestaurantService WITH PASSWORD = "MyReallyBadPassword1"' + +docker exec -it restaurant-server /opt/mssql-tools/bin/sqlcmd \ + -S localhost -U SA -P '5g5R%pY!G8kt' \ + -d RestaurantDB \ + -Q 'CREATE USER [RestaurantService] FOR LOGIN [RestaurantService]' + +docker exec -it restaurant-server /opt/mssql-tools/bin/sqlcmd \ + -S localhost -U SA -P '5g5R%pY!G8kt' \ + -d RestaurantDB \ + -Q "EXEC sp_addrolemember N'db_owner', N'RestaurantService'" diff --git a/stopsql.sh b/stopsql.sh new file mode 100755 index 00000000..35fbacba --- /dev/null +++ b/stopsql.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +docker stop restaurant-server +docker rm restaurant-server \ No newline at end of file