diff --git a/.gitignore b/.gitignore index fd5204b5..d9edd238 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ *.suo *.user *.sln.docstates +.vs/ # Build results [Dd]ebug/ diff --git a/.vs/config/applicationhost.config b/.vs/config/applicationhost.config deleted file mode 100644 index bc28afba..00000000 --- a/.vs/config/applicationhost.config +++ /dev/null @@ -1,1046 +0,0 @@ - - - - - - - - -
-
-
-
-
-
-
-
- - - -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
- -
-
-
-
-
-
- -
-
-
-
-
- -
-
-
- -
-
- -
-
- -
-
-
- - -
-
-
-
-
-
- -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/AcceptanceTestsBase.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/AcceptanceTestsBase.cs index 40aa3945..d0594337 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/AcceptanceTestsBase.cs +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/AcceptanceTestsBase.cs @@ -1,7 +1,10 @@ using System; using System.Data.Common; +using System.Globalization; +using System.IO; using System.Net; using System.Net.Http; +using System.Net.Http.Formatting; using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; @@ -10,6 +13,10 @@ using JSONAPI.Json; using Microsoft.Owin.Testing; using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Linq; +using Owin; namespace JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests { @@ -20,37 +27,58 @@ public abstract class AcceptanceTestsBase private static readonly Regex GuidRegex = new Regex(@"\b[A-F0-9]{8}(?:-[A-F0-9]{4}){3}-[A-F0-9]{12}\b", RegexOptions.IgnoreCase); //private static readonly Regex StackTraceRegex = new Regex(@"""stackTrace"":[\s]*""[\w\:\\\.\s\,\-]*"""); private static readonly Regex StackTraceRegex = new Regex(@"""stackTrace""[\s]*:[\s]*"".*?"""); - private static readonly Uri BaseUri = new Uri("https://www.example.com"); + protected static Uri BaseUri = new Uri("https://www.example.com"); protected static DbConnection GetEffortConnection() { return TestHelpers.GetEffortConnection(@"Data"); } - protected static async Task AssertResponseContent(HttpResponseMessage response, string expectedResponseTextResourcePath, HttpStatusCode expectedStatusCode, bool redactErrorData = false) + protected virtual async Task AssertResponseContent(HttpResponseMessage response, string expectedResponseTextResourcePath, HttpStatusCode expectedStatusCode, bool redactErrorData = false) { var responseContent = await response.Content.ReadAsStringAsync(); - var expectedResponse = - JsonHelpers.MinifyJson(TestHelpers.ReadEmbeddedFile(expectedResponseTextResourcePath)); + var expectedResponse = ExpectedResponse(expectedResponseTextResourcePath); string actualResponse; if (redactErrorData) { var redactedResponse = GuidRegex.Replace(responseContent, "{{SOME_GUID}}"); actualResponse = StackTraceRegex.Replace(redactedResponse, "\"stackTrace\":\"{{STACK_TRACE}}\""); + actualResponse.Should().Be(expectedResponse); } else { actualResponse = responseContent; + JsonSerializerSettings settings = new JsonSerializerSettings + { + DateTimeZoneHandling = DateTimeZoneHandling.Utc, + DateFormatString = "yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fffffff+00:00", + Culture = CultureInfo.InvariantCulture, + Formatting = Formatting.Indented + }; + + var actualResponseJObject = JsonConvert.DeserializeObject(actualResponse) as JObject; + var expectedResponseJObject = JsonConvert.DeserializeObject(expectedResponse) as JObject; + var equals = JToken.DeepEquals(actualResponseJObject, expectedResponseJObject); + if (!equals) + { + Assert.Fail("should be: " + JsonConvert.SerializeObject(expectedResponseJObject, settings) + "\n but was: " + JsonConvert.SerializeObject(actualResponseJObject, settings)); + } } - actualResponse.Should().Be(expectedResponse); response.Content.Headers.ContentType.MediaType.Should().Be(JsonApiContentType); response.Content.Headers.ContentType.CharSet.Should().Be("utf-8"); response.StatusCode.Should().Be(expectedStatusCode); } + protected virtual string ExpectedResponse(string expectedResponseTextResourcePath) + { + var expectedResponse = + JsonHelpers.MinifyJson(TestHelpers.ReadEmbeddedFile(expectedResponseTextResourcePath)); + return expectedResponse; + } + #region GET protected async Task SubmitGet(DbConnection effortConnection, string requestPath) @@ -58,7 +86,7 @@ protected async Task SubmitGet(DbConnection effortConnectio using (var server = TestServer.Create(app => { var startup = new Startup(() => new TestDbContext(effortConnection, false)); - startup.Configuration(app); + StartupConfiguration(startup, app); })) { var uri = new Uri(BaseUri, requestPath); @@ -75,7 +103,7 @@ protected async Task SubmitPost(DbConnection effortConnecti using (var server = TestServer.Create(app => { var startup = new Startup(() => new TestDbContext(effortConnection, false)); - startup.Configuration(app); + StartupConfiguration(startup, app); })) { var uri = new Uri(BaseUri, requestPath); @@ -100,7 +128,7 @@ protected async Task SubmitPatch(DbConnection effortConnect using (var server = TestServer.Create(app => { var startup = new Startup(() => new TestDbContext(effortConnection, false)); - startup.Configuration(app); + StartupConfiguration(startup, app); })) { var uri = new Uri(BaseUri, requestPath); @@ -124,7 +152,7 @@ protected async Task SubmitDelete(DbConnection effortConnec using (var server = TestServer.Create(app => { var startup = new Startup(() => new TestDbContext(effortConnection, false)); - startup.Configuration(app); + StartupConfiguration(startup, app); })) { var uri = new Uri(BaseUri, requestPath); @@ -137,5 +165,22 @@ protected async Task SubmitDelete(DbConnection effortConnec } #endregion + + + + #region configure startup + + /// + /// Startup process was divided into 4 steps to support better acceptance tests. + /// This method can be overridden by subclass to change behavior of setup. + /// + /// + /// + protected virtual void StartupConfiguration(Startup startup, IAppBuilder app) + { + startup.Configuration(app); + } + + #endregion } } diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/BaseUrlTest.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/BaseUrlTest.cs new file mode 100644 index 00000000..cda8ccf8 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/BaseUrlTest.cs @@ -0,0 +1,166 @@ +using System; +using System.Data.SqlTypes; +using System.Linq; +using System.Net; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using FluentAssertions; +using JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Models; +using JSONAPI.Http; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Owin; + +namespace JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests +{ + [TestClass] + public class BaseUrlTest : AcceptanceTestsBase + { + [TestInitialize] + public void TestInit() + { + if (!BaseUri.AbsoluteUri.EndsWith("api/")) + { + BaseUri = new Uri(BaseUri.AbsoluteUri + "api/"); + } + } + [TestCleanup] + public void TestCleanup() + { + if (BaseUri.AbsoluteUri.EndsWith("api/")) + { + BaseUri = new Uri(BaseUri.AbsoluteUri.Substring(0,BaseUri.AbsoluteUri.Length -4)); + } + } + + // custom startup process for this test + protected override void StartupConfiguration(Startup startup, IAppBuilder app) + { + + var configuration = startup.BuildConfiguration(); + // here we add the custom BaseUrlServcie + configuration.CustomBaseUrlService = new BaseUrlService("api"); + var configurator = startup.BuildAutofacConfigurator(app); + var httpConfig = startup.BuildHttpConfiguration(); + startup.MergeAndSetupConfiguration(app, configurator, httpConfig, configuration); + } + + // custom expected response method + protected override string ExpectedResponse(string expectedResponseTextResourcePath) + { + var expected = base.ExpectedResponse(expectedResponseTextResourcePath); + return Regex.Replace(expected, @"www\.example\.com\/", @"www.example.com/api/"); + } + + // copied some tests in here + + // copied from ComputedIdTests + + [TestMethod] + [DeploymentItem(@"Data\Comment.csv", @"Data")] + [DeploymentItem(@"Data\Language.csv", @"Data")] + [DeploymentItem(@"Data\LanguageUserLink.csv", @"Data")] + [DeploymentItem(@"Data\Post.csv", @"Data")] + [DeploymentItem(@"Data\PostTagLink.csv", @"Data")] + [DeploymentItem(@"Data\Tag.csv", @"Data")] + [DeploymentItem(@"Data\User.csv", @"Data")] + public async Task Get_resource_with_computed_id_by_id() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitGet(effortConnection, "language-user-links/9001_402"); + + await AssertResponseContent(response, @"Fixtures\ComputedId\Responses\Get_resource_with_computed_id_by_id_Response.json", HttpStatusCode.OK); + } + } + + + // copied from CreatingResourcesTests + + + [TestMethod] + [DeploymentItem(@"Data\PostLongId.csv", @"Data")] + public async Task PostLongId_with_client_provided_id() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitPost(effortConnection, "post-long-ids", @"Fixtures\CreatingResources\Requests\PostLongId_with_client_provided_id_Request.json"); + + await AssertResponseContent(response, @"Fixtures\CreatingResources\Responses\PostLongId_with_client_provided_id_Response.json", HttpStatusCode.OK); + + using (var dbContext = new TestDbContext(effortConnection, false)) + { + var allPosts = dbContext.PostsLongId.ToArray(); + allPosts.Length.Should().Be(5); + var actualPost = allPosts.First(t => t.Id == 205); + actualPost.Id.Should().Be(205); + actualPost.Title.Should().Be("Added post"); + actualPost.Content.Should().Be("Added post content"); + actualPost.Created.Should().Be(new DateTimeOffset(2015, 03, 11, 04, 31, 0, new TimeSpan(0))); + } + } + } + + + + // copied from DeletingResourcesTests + + [TestMethod] + [DeploymentItem(@"Data\PostID.csv", @"Data")] + public async Task DeleteID() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitDelete(effortConnection, "post-i-ds/203"); + + var responseContent = await response.Content.ReadAsStringAsync(); + responseContent.Should().Be(""); + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + + using (var dbContext = new TestDbContext(effortConnection, false)) + { + var allPosts = dbContext.PostsID.ToArray(); + allPosts.Length.Should().Be(3); + var actualPosts = allPosts.FirstOrDefault(t => t.ID == "203"); + actualPosts.Should().BeNull(); + } + } + } + + + + // copied from FetchingResourcesTests + + [TestMethod] + [DeploymentItem(@"Data\Comment.csv", @"Data")] + [DeploymentItem(@"Data\Post.csv", @"Data")] + [DeploymentItem(@"Data\PostTagLink.csv", @"Data")] + [DeploymentItem(@"Data\Tag.csv", @"Data")] + [DeploymentItem(@"Data\User.csv", @"Data")] + public async Task GetWithFilter() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitGet(effortConnection, "posts?filter[title]=Post 4"); + + await AssertResponseContent(response, @"Fixtures\FetchingResources\GetWithFilterResponse.json", HttpStatusCode.OK); + } + } + + [TestMethod] + [DeploymentItem(@"Data\Comment.csv", @"Data")] + [DeploymentItem(@"Data\Post.csv", @"Data")] + [DeploymentItem(@"Data\PostTagLink.csv", @"Data")] + [DeploymentItem(@"Data\Tag.csv", @"Data")] + [DeploymentItem(@"Data\User.csv", @"Data")] + public async Task GetById() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitGet(effortConnection, "posts/202"); + + await AssertResponseContent(response, @"Fixtures\FetchingResources\GetByIdResponse.json", HttpStatusCode.OK); + } + } + + } +} diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/CreatingResourcesTests.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/CreatingResourcesTests.cs index 40a973df..b189e933 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/CreatingResourcesTests.cs +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/CreatingResourcesTests.cs @@ -39,6 +39,52 @@ public async Task Post_with_client_provided_id() } } + [TestMethod] + [DeploymentItem(@"Data\PostID.csv", @"Data")] + public async Task PostID_with_client_provided_id() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitPost(effortConnection, "post-i-ds", @"Fixtures\CreatingResources\Requests\PostID_with_client_provided_id_Request.json"); + + await AssertResponseContent(response, @"Fixtures\CreatingResources\Responses\PostID_with_client_provided_id_Response.json", HttpStatusCode.OK); + + using (var dbContext = new TestDbContext(effortConnection, false)) + { + var allPosts = dbContext.PostsID.ToArray(); + allPosts.Length.Should().Be(5); + var actualPost = allPosts.First(t => t.ID == "205"); + actualPost.ID.Should().Be("205"); + actualPost.Title.Should().Be("Added post"); + actualPost.Content.Should().Be("Added post content"); + actualPost.Created.Should().Be(new DateTimeOffset(2015, 03, 11, 04, 31, 0, new TimeSpan(0))); + } + } + } + + [TestMethod] + [DeploymentItem(@"Data\PostLongId.csv", @"Data")] + public async Task PostLongId_with_client_provided_id() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitPost(effortConnection, "post-long-ids", @"Fixtures\CreatingResources\Requests\PostLongId_with_client_provided_id_Request.json"); + + await AssertResponseContent(response, @"Fixtures\CreatingResources\Responses\PostLongId_with_client_provided_id_Response.json", HttpStatusCode.OK); + + using (var dbContext = new TestDbContext(effortConnection, false)) + { + var allPosts = dbContext.PostsLongId.ToArray(); + allPosts.Length.Should().Be(5); + var actualPost = allPosts.First(t => t.Id == 205); + actualPost.Id.Should().Be(205); + actualPost.Title.Should().Be("Added post"); + actualPost.Content.Should().Be("Added post content"); + actualPost.Created.Should().Be(new DateTimeOffset(2015, 03, 11, 04, 31, 0, new TimeSpan(0))); + } + } + } + [TestMethod] [DeploymentItem(@"Data\Comment.csv", @"Data")] [DeploymentItem(@"Data\Post.csv", @"Data")] @@ -66,5 +112,34 @@ public async Task Post_with_empty_id() } } } + + + [TestMethod] + [DeploymentItem(@"Data\Comment.csv", @"Data")] + [DeploymentItem(@"Data\Post.csv", @"Data")] + [DeploymentItem(@"Data\PostTagLink.csv", @"Data")] + [DeploymentItem(@"Data\Tag.csv", @"Data")] + [DeploymentItem(@"Data\User.csv", @"Data")] + public async Task Post_with_empty_id_and_include() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitPost(effortConnection, "posts?include=author", @"Fixtures\CreatingResources\Requests\Post_with_empty_id_Request.json"); + + await AssertResponseContent(response, @"Fixtures\CreatingResources\Responses\Post_with_empty_id_and_include_author_Response.json", HttpStatusCode.OK); + + using (var dbContext = new TestDbContext(effortConnection, false)) + { + var allPosts = dbContext.Posts.ToArray(); + allPosts.Length.Should().Be(5); + var actualPost = allPosts.First(t => t.Id == "230"); + actualPost.Id.Should().Be("230"); + actualPost.Title.Should().Be("New post"); + actualPost.Content.Should().Be("The server generated my ID"); + actualPost.Created.Should().Be(new DateTimeOffset(2015, 04, 13, 12, 09, 0, new TimeSpan(0, 3, 0, 0))); + actualPost.AuthorId.Should().Be("401"); + } + } + } } } diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Data/Child.csv b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Data/Child.csv new file mode 100644 index 00000000..b529aab6 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Data/Child.csv @@ -0,0 +1,7 @@ +Id,ChildDescription,MasterId +7500,"Child 1 Description",1500 +7501,"Child 2 Description",1501 +7502,"Child 3 Description",1501 +7503,"Child 4 Description",1503 +7504,"Child 5 Description",1503 +7505,"Child 6 Description",1503 \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Data/Master.csv b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Data/Master.csv new file mode 100644 index 00000000..2a582cc4 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Data/Master.csv @@ -0,0 +1,5 @@ +Id,Description +1500,"Master 1 Description" +1501,"Master 2 Description" +1502,"Master 3 Description" +1503,"Master 4 Description" diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Data/PostID.csv b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Data/PostID.csv new file mode 100644 index 00000000..544b39c7 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Data/PostID.csv @@ -0,0 +1,5 @@ +ID,Title,Content,Created,AuthorId +"201","Post 1","Post 1 content","2015-01-31T14:00Z" +"202","Post 2","Post 2 content","2015-02-05T08:10Z" +"203","Post 3","Post 3 content","2015-02-07T11:11Z" +"204","Post 4","Post 4 content","2015-02-08T06:59Z" \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Data/PostLongId.csv b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Data/PostLongId.csv new file mode 100644 index 00000000..ac3f5a42 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Data/PostLongId.csv @@ -0,0 +1,5 @@ +Id,Title,Content,Created,AuthorId +201,"Post 1","Post 1 content","2015-01-31T14:00Z" +202,"Post 2","Post 2 content","2015-02-05T08:10Z" +203,"Post 3","Post 3 content","2015-02-07T11:11Z" +204,"Post 4","Post 4 content","2015-02-08T06:59Z" \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/DeletingResourcesTests.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/DeletingResourcesTests.cs index c5902256..f77715f5 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/DeletingResourcesTests.cs +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/DeletingResourcesTests.cs @@ -11,11 +11,11 @@ namespace JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests public class DeletingResourcesTests : AcceptanceTestsBase { [TestMethod] - [DeploymentItem(@"Data\Comment.csv", @"Acceptance\Data")] - [DeploymentItem(@"Data\Post.csv", @"Acceptance\Data")] - [DeploymentItem(@"Data\PostTagLink.csv", @"Acceptance\Data")] - [DeploymentItem(@"Data\Tag.csv", @"Acceptance\Data")] - [DeploymentItem(@"Data\User.csv", @"Acceptance\Data")] + [DeploymentItem(@"Data\Comment.csv", @"Data")] + [DeploymentItem(@"Data\Post.csv", @"Data")] + [DeploymentItem(@"Data\PostTagLink.csv", @"Data")] + [DeploymentItem(@"Data\Tag.csv", @"Data")] + [DeploymentItem(@"Data\User.csv", @"Data")] public async Task Delete() { using (var effortConnection = GetEffortConnection()) @@ -28,10 +28,54 @@ public async Task Delete() using (var dbContext = new TestDbContext(effortConnection, false)) { - var allTodos = dbContext.Posts.ToArray(); - allTodos.Length.Should().Be(3); - var actualTodo = allTodos.FirstOrDefault(t => t.Id == "203"); - actualTodo.Should().BeNull(); + var allPosts = dbContext.Posts.ToArray(); + allPosts.Length.Should().Be(3); + var actualPosts = allPosts.FirstOrDefault(t => t.Id == "203"); + actualPosts.Should().BeNull(); + } + } + } + + [TestMethod] + [DeploymentItem(@"Data\PostID.csv", @"Data")] + public async Task DeleteID() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitDelete(effortConnection, "post-i-ds/203"); + + var responseContent = await response.Content.ReadAsStringAsync(); + responseContent.Should().Be(""); + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + + using (var dbContext = new TestDbContext(effortConnection, false)) + { + var allPosts = dbContext.PostsID.ToArray(); + allPosts.Length.Should().Be(3); + var actualPosts = allPosts.FirstOrDefault(t => t.ID == "203"); + actualPosts.Should().BeNull(); + } + } + } + + [TestMethod] + [DeploymentItem(@"Data\PostLongId.csv", @"Data")] + public async Task DeleteLongId() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitDelete(effortConnection, "post-long-ids/203"); + + var responseContent = await response.Content.ReadAsStringAsync(); + responseContent.Should().Be(""); + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + + using (var dbContext = new TestDbContext(effortConnection, false)) + { + var allPosts = dbContext.PostsLongId.ToArray(); + allPosts.Length.Should().Be(3); + var actualPosts = allPosts.FirstOrDefault(t => t.Id == 203); + actualPosts.Should().BeNull(); } } } diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/FetchingResourcesTests.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/FetchingResourcesTests.cs index 5ec6cc98..39eb2938 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/FetchingResourcesTests.cs +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/FetchingResourcesTests.cs @@ -99,6 +99,40 @@ public async Task Get_related_to_many() } } + + [TestMethod] + [DeploymentItem(@"Data\Comment.csv", @"Data")] + [DeploymentItem(@"Data\Post.csv", @"Data")] + [DeploymentItem(@"Data\PostTagLink.csv", @"Data")] + [DeploymentItem(@"Data\Tag.csv", @"Data")] + [DeploymentItem(@"Data\User.csv", @"Data")] + public async Task Get_related_to_many_included() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitGet(effortConnection, "posts/201/comments?include=author"); + + await AssertResponseContent(response, @"Fixtures\FetchingResources\Get_related_to_many_include_response.json", HttpStatusCode.OK); + } + } + + + [TestMethod] + [DeploymentItem(@"Data\Comment.csv", @"Data")] + [DeploymentItem(@"Data\Post.csv", @"Data")] + [DeploymentItem(@"Data\PostTagLink.csv", @"Data")] + [DeploymentItem(@"Data\Tag.csv", @"Data")] + [DeploymentItem(@"Data\User.csv", @"Data")] + public async Task Get_related_to_many_included_external() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitGet(effortConnection, "users/401/posts?include=tags"); + + await AssertResponseContent(response, @"Fixtures\FetchingResources\Get_related_to_many_include_external_response.json", HttpStatusCode.OK); + } + } + [TestMethod] [DeploymentItem(@"Data\Comment.csv", @"Data")] [DeploymentItem(@"Data\Post.csv", @"Data")] @@ -115,6 +149,23 @@ public async Task Get_related_to_one() } } + + [TestMethod] + [DeploymentItem(@"Data\Comment.csv", @"Data")] + [DeploymentItem(@"Data\Post.csv", @"Data")] + [DeploymentItem(@"Data\PostTagLink.csv", @"Data")] + [DeploymentItem(@"Data\Tag.csv", @"Data")] + [DeploymentItem(@"Data\User.csv", @"Data")] + public async Task Get_included_to_one() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitGet(effortConnection, "posts/201?include=author"); + + await AssertResponseContent(response, @"Fixtures\FetchingResources\Get_included_to_one_response.json", HttpStatusCode.OK); + } + } + [TestMethod] [DeploymentItem(@"Data\Comment.csv", @"Data")] [DeploymentItem(@"Data\Post.csv", @"Data")] @@ -183,5 +234,18 @@ await AssertResponseContent(response, HttpStatusCode.OK); } } + + [TestMethod] + [DeploymentItem(@"Data\Master.csv", @"Data")] + [DeploymentItem(@"Data\Child.csv", @"Data")] + public async Task Get_related_to_many_integer_key() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitGet(effortConnection, "masters/1501/children"); + + await AssertResponseContent(response, @"Fixtures\FetchingResources\Get_related_to_many_integer_key_response.json", HttpStatusCode.OK); + } + } } } diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/AttributeSerialization/Attributes_of_various_types_serialize_correctly.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/AttributeSerialization/Attributes_of_various_types_serialize_correctly.json index ba0978cf..ca94936a 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/AttributeSerialization/Attributes_of_various_types_serialize_correctly.json +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/AttributeSerialization/Attributes_of_various_types_serialize_correctly.json @@ -1,89 +1,110 @@ { - "data": [ - { - "type": "samples", - "id": "1", - "attributes": { - "boolean-field": false, - "byte-field": 0, - "complex-attribute-field": null, - "date-time-field": "0001-01-01T00:00:00", - "date-time-offset-field": "0001-01-01T00:00:00.0000000+00:00", - "decimal-field": "0", - "double-field": 0.0, - "enum-field": 0, - "guid-field": "00000000-0000-0000-0000-000000000000", - "int16-field": 0, - "int32-field": 0, - "int64-field": 0, - "nullable-boolean-field": false, - "nullable-byte-field": null, - "nullable-date-time-field": null, - "nullable-date-time-offset-field": null, - "nullable-decimal-field": null, - "nullable-double-field": null, - "nullable-enum-field": null, - "nullable-guid-field": null, - "nullable-int16-field": null, - "nullable-int32-field": null, - "nullable-int64-field": null, - "nullable-sbyte-field": null, - "nullable-single-field": null, - "nullable-uint16-field": null, - "nullable-uint32-field": null, - "nullable-uint64-field": null, - "sbyte-field": 0, - "single-field": 0.0, - "string-field": null, - "uint16-field": 0, - "uint32-field": 0, - "uint64-field": 0 - } + "data": [ + { + "type": "samples", + "id": "1", + "attributes": { + "boolean-field": false, + "byte-field": 0, + "complex-attribute-field": null, + "date-time-field": "0001-01-01T00:00:00", + "date-time-offset-field": "0001-01-01T00:00:00.0000000+00:00", + "decimal-field": "0", + "double-field": 0.0, + "enum-field": 0, + "guid-field": "00000000-0000-0000-0000-000000000000", + "int16-field": 0, + "int32-field": 0, + "int64-field": 0, + "j-token-array-field": null, + "j-token-object-field": null, + "j-token-string-field": null, + "nullable-boolean-field": false, + "nullable-byte-field": null, + "nullable-date-time-field": null, + "nullable-date-time-offset-field": null, + "nullable-decimal-field": null, + "nullable-double-field": null, + "nullable-enum-field": null, + "nullable-guid-field": null, + "nullable-int16-field": null, + "nullable-int32-field": null, + "nullable-int64-field": null, + "nullable-sbyte-field": null, + "nullable-single-field": null, + "nullable-uint16-field": null, + "nullable-uint32-field": null, + "nullable-uint64-field": null, + "sbyte-field": 0, + "single-field": 0.0, + "string-field": null, + "uint16-field": 0, + "uint32-field": 0, + "uint64-field": 0 + } + }, + { + "type": "samples", + "id": "2", + "attributes": { + "boolean-field": true, + "byte-field": 253, + "complex-attribute-field": { + "foo": { + "baz": [ 11 ] + }, + "bar": 5 }, - { - "type": "samples", - "id": "2", - "attributes": { - "boolean-field": true, - "byte-field": 253, - "complex-attribute-field": { - "foo": { - "baz": [ 11 ] - }, - "bar": 5 - }, - "date-time-field": "1776-07-04T00:00:00", - "date-time-offset-field": "1776-07-04T00:00:00.0000000-05:00", - "decimal-field": "1056789.123", - "double-field": 1056789.123, - "enum-field": 1, - "guid-field": "6566f9b4-5245-40de-890d-98b40a4ad656", - "int16-field": 32000, - "int32-field": 2000000000, - "int64-field": 9223372036854775807, - "nullable-boolean-field": true, - "nullable-byte-field": 253, - "nullable-date-time-field": "1776-07-04T00:00:00", - "nullable-date-time-offset-field": "1776-07-04T00:00:00.0000000-05:00", - "nullable-decimal-field": "1056789.123", - "nullable-double-field": 1056789.123, - "nullable-enum-field": 2, - "nullable-guid-field": "3d1fb81e-43ee-4d04-af91-c8a326341293", - "nullable-int16-field": 32000, - "nullable-int32-field": 2000000000, - "nullable-int64-field": 9223372036854775807, - "nullable-sbyte-field": 123, - "nullable-single-field": 1056789.13, - "nullable-uint16-field": 64000, - "nullable-uint32-field": 3000000000, - "nullable-uint64-field": 9223372036854775808, - "sbyte-field": 123, - "single-field": 1056789.13, - "string-field": "Some string 156", - "uint16-field": 64000, - "uint32-field": 3000000000, - "uint64-field": 9223372036854775808 - } - } - ] + "date-time-field": "1776-07-04T00:00:00", + "date-time-offset-field": "1776-07-04T00:00:00.0000000-05:00", + "decimal-field": "1056789.123", + "double-field": 1056789.123, + "enum-field": 1, + "guid-field": "6566f9b4-5245-40de-890d-98b40a4ad656", + "int16-field": 32000, + "int32-field": 2000000000, + "int64-field": 9223372036854775807, + "j-token-array-field": [ + { + "my-field1": "George Washington", + "overridden-field2": null, + "MyField3": 216 + }, + { + "my-field1": "Thomas Jefferson", + "overridden-field2": false, + "MyField3": 631 + } + ], + "j-token-object-field": { + "my-field1": "Abraham Lincoln", + "overridden-field2": true, + "MyField3": 439 + }, + "j-token-string-field": "Andrew Jackson", + "nullable-boolean-field": true, + "nullable-byte-field": 253, + "nullable-date-time-field": "1776-07-04T00:00:00", + "nullable-date-time-offset-field": "1776-07-04T00:00:00.0000000-05:00", + "nullable-decimal-field": "1056789.123", + "nullable-double-field": 1056789.123, + "nullable-enum-field": 2, + "nullable-guid-field": "3d1fb81e-43ee-4d04-af91-c8a326341293", + "nullable-int16-field": 32000, + "nullable-int32-field": 2000000000, + "nullable-int64-field": 9223372036854775807, + "nullable-sbyte-field": 123, + "nullable-single-field": 1056789.13, + "nullable-uint16-field": 64000, + "nullable-uint32-field": 3000000000, + "nullable-uint64-field": 9223372036854775808, + "sbyte-field": 123, + "single-field": 1056789.13, + "string-field": "Some string 156", + "uint16-field": 64000, + "uint32-field": 3000000000, + "uint64-field": 9223372036854775808 + } + } + ] } diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/CreatingResources/Requests/PostID_with_client_provided_id_Request.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/CreatingResources/Requests/PostID_with_client_provided_id_Request.json new file mode 100644 index 00000000..41424e02 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/CreatingResources/Requests/PostID_with_client_provided_id_Request.json @@ -0,0 +1,11 @@ +{ + "data": { + "type": "post-i-ds", + "id": "205", + "attributes": { + "title": "Added post", + "content": "Added post content", + "created": "2015-03-11T04:31:00+00:00" + } + } +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/CreatingResources/Requests/PostLongId_with_client_provided_id_Request.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/CreatingResources/Requests/PostLongId_with_client_provided_id_Request.json new file mode 100644 index 00000000..b52fe5c4 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/CreatingResources/Requests/PostLongId_with_client_provided_id_Request.json @@ -0,0 +1,11 @@ +{ + "data": { + "type": "post-long-ids", + "id": "205", + "attributes": { + "title": "Added post", + "content": "Added post content", + "created": "2015-03-11T04:31:00+00:00" + } + } +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/CreatingResources/Responses/PostID_with_client_provided_id_Response.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/CreatingResources/Responses/PostID_with_client_provided_id_Response.json new file mode 100644 index 00000000..7122e9e9 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/CreatingResources/Responses/PostID_with_client_provided_id_Response.json @@ -0,0 +1,11 @@ +{ + "data": { + "type": "post-i-ds", + "id": "205", + "attributes": { + "content": "Added post content", + "created": "2015-03-11T04:31:00.0000000+00:00", + "title": "Added post" + } + } +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/CreatingResources/Responses/PostLongId_with_client_provided_id_Response.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/CreatingResources/Responses/PostLongId_with_client_provided_id_Response.json new file mode 100644 index 00000000..dc5ee0f8 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/CreatingResources/Responses/PostLongId_with_client_provided_id_Response.json @@ -0,0 +1,11 @@ +{ + "data": { + "type": "post-long-ids", + "id": "205", + "attributes": { + "content": "Added post content", + "created": "2015-03-11T04:31:00.0000000+00:00", + "title": "Added post" + } + } +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/CreatingResources/Responses/Post_with_empty_id_and_include_author_Response.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/CreatingResources/Responses/Post_with_empty_id_and_include_author_Response.json new file mode 100644 index 00000000..f9996946 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/CreatingResources/Responses/Post_with_empty_id_and_include_author_Response.json @@ -0,0 +1,65 @@ +{ + "data": { + "type": "posts", + "id": "230", + "attributes": { + "content": "The server generated my ID", + "created": "2015-04-13T09:09:00.0000000+00:00", + "title": "New post" + }, + "relationships": { + "author": { + "links": { + "self": "https://www.example.com/posts/230/relationships/author", + "related": "https://www.example.com/posts/230/author" + }, + "data": { + "type": "users", + "id": "401" + } + }, + "comments": { + "links": { + "self": "https://www.example.com/posts/230/relationships/comments", + "related": "https://www.example.com/posts/230/comments" + } + }, + "tags": { + "links": { + "self": "https://www.example.com/posts/230/relationships/tags", + "related": "https://www.example.com/posts/230/tags" + } + } + } + }, + "included": [ + { + "type": "users", + "id": "401", + "attributes": { + "first-name": "Alice", + "last-name": "Smith" + }, + "relationships": { + "comments": { + "links": { + "self": "https://www.example.com/users/401/relationships/comments", + "related": "https://www.example.com/users/401/comments" + } + }, + "posts": { + "links": { + "self": "https://www.example.com/users/401/relationships/posts", + "related": "https://www.example.com/users/401/posts" + } + }, + "user-groups": { + "links": { + "self": "https://www.example.com/users/401/relationships/user-groups", + "related": "https://www.example.com/users/401/user-groups" + } + } + } + } + ] +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResources/Get_included_to_one_response.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResources/Get_included_to_one_response.json new file mode 100644 index 00000000..98fcd282 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResources/Get_included_to_one_response.json @@ -0,0 +1,65 @@ +{ + "data": { + "type": "posts", + "id": "201", + "attributes": { + "content": "Post 1 content", + "created": "2015-01-31T14:00:00.0000000+00:00", + "title": "Post 1" + }, + "relationships": { + "author": { + "links": { + "self": "https://www.example.com/posts/201/relationships/author", + "related": "https://www.example.com/posts/201/author" + }, + "data": { + "type": "users", + "id": "401" + } + }, + "comments": { + "links": { + "self": "https://www.example.com/posts/201/relationships/comments", + "related": "https://www.example.com/posts/201/comments" + } + }, + "tags": { + "links": { + "self": "https://www.example.com/posts/201/relationships/tags", + "related": "https://www.example.com/posts/201/tags" + } + } + } + }, + "included": [ + { + "type": "users", + "id": "401", + "attributes": { + "first-name": "Alice", + "last-name": "Smith" + }, + "relationships": { + "comments": { + "links": { + "self": "https://www.example.com/users/401/relationships/comments", + "related": "https://www.example.com/users/401/comments" + } + }, + "posts": { + "links": { + "self": "https://www.example.com/users/401/relationships/posts", + "related": "https://www.example.com/users/401/posts" + } + }, + "user-groups": { + "links": { + "self": "https://www.example.com/users/401/relationships/user-groups", + "related": "https://www.example.com/users/401/user-groups" + } + } + } + } + ] +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResources/Get_related_to_many_include_external_response.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResources/Get_related_to_many_include_external_response.json new file mode 100644 index 00000000..e8008645 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResources/Get_related_to_many_include_external_response.json @@ -0,0 +1,164 @@ +{ + "data": [ + { + "type": "posts", + "id": "201", + "attributes": { + "content": "Post 1 content", + "created": "2015-01-31T14:00:00.0000000+00:00", + "title": "Post 1" + }, + "relationships": { + "author": { + "links": { + "self": "https://www.example.com/posts/201/relationships/author", + "related": "https://www.example.com/posts/201/author" + } + }, + "comments": { + "links": { + "self": "https://www.example.com/posts/201/relationships/comments", + "related": "https://www.example.com/posts/201/comments" + } + }, + "tags": { + "links": { + "self": "https://www.example.com/posts/201/relationships/tags", + "related": "https://www.example.com/posts/201/tags" + }, + "data": [ + { + "type": "tags", + "id": "301" + }, + { + "type": "tags", + "id": "302" + } + ] + } + } + }, + { + "type": "posts", + "id": "202", + "attributes": { + "content": "Post 2 content", + "created": "2015-02-05T08:10:00.0000000+00:00", + "title": "Post 2" + }, + "relationships": { + "author": { + "links": { + "self": "https://www.example.com/posts/202/relationships/author", + "related": "https://www.example.com/posts/202/author" + } + }, + "comments": { + "links": { + "self": "https://www.example.com/posts/202/relationships/comments", + "related": "https://www.example.com/posts/202/comments" + } + }, + "tags": { + "links": { + "self": "https://www.example.com/posts/202/relationships/tags", + "related": "https://www.example.com/posts/202/tags" + }, + "data": [ + { + "type": "tags", + "id": "302" + }, + { + "type": "tags", + "id": "303" + } + ] + } + } + }, + { + "type": "posts", + "id": "203", + "attributes": { + "content": "Post 3 content", + "created": "2015-02-07T11:11:00.0000000+00:00", + "title": "Post 3" + }, + "relationships": { + "author": { + "links": { + "self": "https://www.example.com/posts/203/relationships/author", + "related": "https://www.example.com/posts/203/author" + } + }, + "comments": { + "links": { + "self": "https://www.example.com/posts/203/relationships/comments", + "related": "https://www.example.com/posts/203/comments" + } + }, + "tags": { + "links": { + "self": "https://www.example.com/posts/203/relationships/tags", + "related": "https://www.example.com/posts/203/tags" + }, + "data": [ + { + "type": "tags", + "id": "303" + } + ] + } + } + } + ], + "included": [ + { + "type": "tags", + "id": "301", + "attributes": { + "name": "Tag A" + }, + "relationships": { + "posts": { + "links": { + "self": "https://www.example.com/tags/301/relationships/posts", + "related": "https://www.example.com/tags/301/posts" + } + } + } + }, + { + "type": "tags", + "id": "302", + "attributes": { + "name": "Tag B" + }, + "relationships": { + "posts": { + "links": { + "self": "https://www.example.com/tags/302/relationships/posts", + "related": "https://www.example.com/tags/302/posts" + } + } + } + }, + { + "type": "tags", + "id": "303", + "attributes": { + "name": "Tag C" + }, + "relationships": { + "posts": { + "links": { + "self": "https://www.example.com/tags/303/relationships/posts", + "related": "https://www.example.com/tags/303/posts" + } + } + } + } + ] +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResources/Get_related_to_many_include_response.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResources/Get_related_to_many_include_response.json new file mode 100644 index 00000000..e1a01091 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResources/Get_related_to_many_include_response.json @@ -0,0 +1,140 @@ +{ + "data": [ + { + "type": "comments", + "id": "101", + "attributes": { + "created": "2015-01-31T14:30:00.0000000+00:00", + "text": "Comment 1" + }, + "relationships": { + "author": { + "links": { + "self": "https://www.example.com/comments/101/relationships/author", + "related": "https://www.example.com/comments/101/author" + }, + "data": { + "type": "users", + "id": "403" + } + }, + "post": { + "links": { + "self": "https://www.example.com/comments/101/relationships/post", + "related": "https://www.example.com/comments/101/post" + } + } + } + }, + { + "type": "comments", + "id": "102", + "attributes": { + "created": "2015-01-31T14:35:00.0000000+00:00", + "text": "Comment 2" + }, + "relationships": { + "author": { + "links": { + "self": "https://www.example.com/comments/102/relationships/author", + "related": "https://www.example.com/comments/102/author" + }, + "data": { + "type": "users", + "id": "402" + } + }, + "post": { + "links": { + "self": "https://www.example.com/comments/102/relationships/post", + "related": "https://www.example.com/comments/102/post" + } + } + } + }, + { + "type": "comments", + "id": "103", + "attributes": { + "created": "2015-01-31T14:41:00.0000000+00:00", + "text": "Comment 3" + }, + "relationships": { + "author": { + "links": { + "self": "https://www.example.com/comments/103/relationships/author", + "related": "https://www.example.com/comments/103/author" + }, + "data": { + "type": "users", + "id": "403" + } + }, + "post": { + "links": { + "self": "https://www.example.com/comments/103/relationships/post", + "related": "https://www.example.com/comments/103/post" + } + } + } + } + ], + "included": [ + { + "type": "users", + "id": "403", + "attributes": { + "first-name": "Charlie", + "last-name": "Michaels" + }, + "relationships": { + "comments": { + "links": { + "self": "https://www.example.com/users/403/relationships/comments", + "related": "https://www.example.com/users/403/comments" + } + }, + "posts": { + "links": { + "self": "https://www.example.com/users/403/relationships/posts", + "related": "https://www.example.com/users/403/posts" + } + }, + "user-groups": { + "links": { + "self": "https://www.example.com/users/403/relationships/user-groups", + "related": "https://www.example.com/users/403/user-groups" + } + } + } + }, + { + "type": "users", + "id": "402", + "attributes": { + "first-name": "Bob", + "last-name": "Jones" + }, + "relationships": { + "comments": { + "links": { + "self": "https://www.example.com/users/402/relationships/comments", + "related": "https://www.example.com/users/402/comments" + } + }, + "posts": { + "links": { + "self": "https://www.example.com/users/402/relationships/posts", + "related": "https://www.example.com/users/402/posts" + } + }, + "user-groups": { + "links": { + "self": "https://www.example.com/users/402/relationships/user-groups", + "related": "https://www.example.com/users/402/user-groups" + } + } + } + } + ] +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResources/Get_related_to_many_integer_key_response.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResources/Get_related_to_many_integer_key_response.json new file mode 100644 index 00000000..f99bab69 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResources/Get_related_to_many_integer_key_response.json @@ -0,0 +1,34 @@ +{ + "data": [ + { + "type": "children", + "id": "7501", + "attributes": { + "child-description": "Child 2 Description" + }, + "relationships": { + "master": { + "links": { + "self": "https://www.example.com/children/7501/relationships/master", + "related": "https://www.example.com/children/7501/master" + } + } + } + }, + { + "type": "children", + "id": "7502", + "attributes": { + "child-description": "Child 3 Description" + }, + "relationships": { + "master": { + "links": { + "self": "https://www.example.com/children/7502/relationships/master", + "related": "https://www.example.com/children/7502/master" + } + } + } + } + ] +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/Pagination/GetAllResponsePaged-2-2.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/Pagination/GetAllResponsePaged-2-2.json new file mode 100644 index 00000000..7bf2f5ba --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/Pagination/GetAllResponsePaged-2-2.json @@ -0,0 +1,66 @@ +{ + "meta": { + "total-pages": 2, + "total-count": 4 + }, + "data": [ + { + "type": "posts", + "id": "203", + "attributes": { + "content": "Post 3 content", + "created": "2015-02-07T11:11:00.0000000+00:00", + "title": "Post 3" + }, + "relationships": { + "author": { + "links": { + "self": "https://www.example.com/posts/203/relationships/author", + "related": "https://www.example.com/posts/203/author" + } + }, + "comments": { + "links": { + "self": "https://www.example.com/posts/203/relationships/comments", + "related": "https://www.example.com/posts/203/comments" + } + }, + "tags": { + "links": { + "self": "https://www.example.com/posts/203/relationships/tags", + "related": "https://www.example.com/posts/203/tags" + } + } + } + }, + { + "type": "posts", + "id": "204", + "attributes": { + "content": "Post 4 content", + "created": "2015-02-08T06:59:00.0000000+00:00", + "title": "Post 4" + }, + "relationships": { + "author": { + "links": { + "self": "https://www.example.com/posts/204/relationships/author", + "related": "https://www.example.com/posts/204/author" + } + }, + "comments": { + "links": { + "self": "https://www.example.com/posts/204/relationships/comments", + "related": "https://www.example.com/posts/204/comments" + } + }, + "tags": { + "links": { + "self": "https://www.example.com/posts/204/relationships/tags", + "related": "https://www.example.com/posts/204/tags" + } + } + } + } + ] +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/Pagination/GetFilterPaged-1-2-sorted-desc.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/Pagination/GetFilterPaged-1-2-sorted-desc.json new file mode 100644 index 00000000..b65cea48 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/Pagination/GetFilterPaged-1-2-sorted-desc.json @@ -0,0 +1,64 @@ +{ + "meta": { + "total-pages": 2, + "total-count": 3 + }, + "data": [ + { + "type": "users", + "id": "410", + "attributes": { + "first-name": "Sally", + "last-name": "Burns" + }, + "relationships": { + "comments": { + "links": { + "self": "https://www.example.com/users/410/relationships/comments", + "related": "https://www.example.com/users/410/comments" + } + }, + "posts": { + "links": { + "self": "https://www.example.com/users/410/relationships/posts", + "related": "https://www.example.com/users/410/posts" + } + }, + "user-groups": { + "links": { + "self": "https://www.example.com/users/410/relationships/user-groups", + "related": "https://www.example.com/users/410/user-groups" + } + } + } + }, + { + "type": "users", + "id": "406", + "attributes": { + "first-name": "Ed", + "last-name": "Burns" + }, + "relationships": { + "comments": { + "links": { + "self": "https://www.example.com/users/406/relationships/comments", + "related": "https://www.example.com/users/406/comments" + } + }, + "posts": { + "links": { + "self": "https://www.example.com/users/406/relationships/posts", + "related": "https://www.example.com/users/406/posts" + } + }, + "user-groups": { + "links": { + "self": "https://www.example.com/users/406/relationships/user-groups", + "related": "https://www.example.com/users/406/user-groups" + } + } + } + } + ] +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/Pagination/GetFilterPaged-1-2-sorted.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/Pagination/GetFilterPaged-1-2-sorted.json new file mode 100644 index 00000000..ee562655 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/Pagination/GetFilterPaged-1-2-sorted.json @@ -0,0 +1,64 @@ +{ + "meta": { + "total-pages": 2, + "total-count": 3 + }, + "data": [ + { + "type": "users", + "id": "409", + "attributes": { + "first-name": "Charlie", + "last-name": "Burns" + }, + "relationships": { + "comments": { + "links": { + "self": "https://www.example.com/users/409/relationships/comments", + "related": "https://www.example.com/users/409/comments" + } + }, + "posts": { + "links": { + "self": "https://www.example.com/users/409/relationships/posts", + "related": "https://www.example.com/users/409/posts" + } + }, + "user-groups": { + "links": { + "self": "https://www.example.com/users/409/relationships/user-groups", + "related": "https://www.example.com/users/409/user-groups" + } + } + } + }, + { + "type": "users", + "id": "406", + "attributes": { + "first-name": "Ed", + "last-name": "Burns" + }, + "relationships": { + "comments": { + "links": { + "self": "https://www.example.com/users/406/relationships/comments", + "related": "https://www.example.com/users/406/comments" + } + }, + "posts": { + "links": { + "self": "https://www.example.com/users/406/relationships/posts", + "related": "https://www.example.com/users/406/posts" + } + }, + "user-groups": { + "links": { + "self": "https://www.example.com/users/406/relationships/user-groups", + "related": "https://www.example.com/users/406/user-groups" + } + } + } + } + ] +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/Pagination/GetFilterPaged-2-1.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/Pagination/GetFilterPaged-2-1.json new file mode 100644 index 00000000..16e92908 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/Pagination/GetFilterPaged-2-1.json @@ -0,0 +1,36 @@ +{ + "meta": { + "total-pages": 3, + "total-count": 3 + }, + "data": [ + { + "type": "users", + "id": "409", + "attributes": { + "first-name": "Charlie", + "last-name": "Burns" + }, + "relationships": { + "comments": { + "links": { + "self": "https://www.example.com/users/409/relationships/comments", + "related": "https://www.example.com/users/409/comments" + } + }, + "posts": { + "links": { + "self": "https://www.example.com/users/409/relationships/posts", + "related": "https://www.example.com/users/409/posts" + } + }, + "user-groups": { + "links": { + "self": "https://www.example.com/users/409/relationships/user-groups", + "related": "https://www.example.com/users/409/user-groups" + } + } + } + } + ] +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/UpdatingResources/Requests/PatchWithAttributeUpdateRequestID.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/UpdatingResources/Requests/PatchWithAttributeUpdateRequestID.json new file mode 100644 index 00000000..ac7fbb4b --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/UpdatingResources/Requests/PatchWithAttributeUpdateRequestID.json @@ -0,0 +1,9 @@ +{ + "data": { + "type": "post-i-ds", + "id": "202", + "attributes": { + "title": "New post title" + } + } +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/UpdatingResources/Requests/PatchWithAttributeUpdateRequestLongId.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/UpdatingResources/Requests/PatchWithAttributeUpdateRequestLongId.json new file mode 100644 index 00000000..7358d7a8 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/UpdatingResources/Requests/PatchWithAttributeUpdateRequestLongId.json @@ -0,0 +1,9 @@ +{ + "data": { + "type": "post-long-ids", + "id": "202", + "attributes": { + "title": "New post title" + } + } +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/UpdatingResources/Responses/PatchWithAttributeUpdateResponseID.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/UpdatingResources/Responses/PatchWithAttributeUpdateResponseID.json new file mode 100644 index 00000000..b0448cb5 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/UpdatingResources/Responses/PatchWithAttributeUpdateResponseID.json @@ -0,0 +1,11 @@ +{ + "data": { + "type": "post-i-ds", + "id": "202", + "attributes": { + "content": "Post 2 content", + "created": "2015-02-05T08:10:00.0000000+00:00", + "title": "New post title" + } + } +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/UpdatingResources/Responses/PatchWithAttributeUpdateResponseLongId.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/UpdatingResources/Responses/PatchWithAttributeUpdateResponseLongId.json new file mode 100644 index 00000000..fea5fa0a --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/UpdatingResources/Responses/PatchWithAttributeUpdateResponseLongId.json @@ -0,0 +1,11 @@ +{ + "data": { + "type": "post-long-ids", + "id": "202", + "attributes": { + "content": "Post 2 content", + "created": "2015-02-05T08:10:00.0000000+00:00", + "title": "New post title" + } + } +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/UpdatingResources/Responses/PatchWithAttributeUpdateWithIncludeResponse.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/UpdatingResources/Responses/PatchWithAttributeUpdateWithIncludeResponse.json new file mode 100644 index 00000000..6bff40d9 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/UpdatingResources/Responses/PatchWithAttributeUpdateWithIncludeResponse.json @@ -0,0 +1,65 @@ +{ + "data": { + "type": "posts", + "id": "202", + "attributes": { + "content": "Post 2 content", + "created": "2015-02-05T08:10:00.0000000+00:00", + "title": "New post title" + }, + "relationships": { + "author": { + "links": { + "self": "https://www.example.com/posts/202/relationships/author", + "related": "https://www.example.com/posts/202/author" + }, + "data": { + "type": "users", + "id": "401" + } + }, + "comments": { + "links": { + "self": "https://www.example.com/posts/202/relationships/comments", + "related": "https://www.example.com/posts/202/comments" + } + }, + "tags": { + "links": { + "self": "https://www.example.com/posts/202/relationships/tags", + "related": "https://www.example.com/posts/202/tags" + } + } + } + }, + "included": [ + { + "type": "users", + "id": "401", + "attributes": { + "first-name": "Alice", + "last-name": "Smith" + }, + "relationships": { + "comments": { + "links": { + "self": "https://www.example.com/users/401/relationships/comments", + "related": "https://www.example.com/users/401/comments" + } + }, + "posts": { + "links": { + "self": "https://www.example.com/users/401/relationships/posts", + "related": "https://www.example.com/users/401/posts" + } + }, + "user-groups": { + "links": { + "self": "https://www.example.com/users/401/relationships/user-groups", + "related": "https://www.example.com/users/401/user-groups" + } + } + } + } + ] +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests.csproj b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests.csproj index 7f2a1699..35d82430 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests.csproj +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests.csproj @@ -61,6 +61,10 @@ ..\packages\Microsoft.Owin.Testing.3.0.0\lib\net45\Microsoft.Owin.Testing.dll + + ..\packages\Newtonsoft.Json.6.0.8\lib\net45\Newtonsoft.Json.dll + True + ..\packages\NMemory.1.0.1\lib\net45\NMemory.dll @@ -71,7 +75,15 @@ + + ..\packages\Microsoft.AspNet.WebApi.Client.5.2.2\lib\net45\System.Net.Http.Formatting.dll + True + + + ..\packages\Microsoft.AspNet.WebApi.Core.5.2.2\lib\net45\System.Web.Http.dll + True + @@ -94,10 +106,12 @@ + + @@ -107,6 +121,10 @@ {76dee472-723b-4be6-8b97-428ac326e30f} JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp + + {AF7861F3-550B-4F70-A33E-1E5F48D39333} + JSONAPI.Autofac + {52b19fd6-efaa-45b5-9c3e-a652e27608d1} JSONAPI @@ -123,9 +141,21 @@ + + Always + + + Always + Always + + Always + + + Always + Always @@ -233,8 +263,27 @@ + + + + + + + + + + + + + + + + + + + diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/PaginationTests.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/PaginationTests.cs new file mode 100644 index 00000000..0db20f57 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/PaginationTests.cs @@ -0,0 +1,77 @@ +using System.Net; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests +{ + [TestClass] + public class PaginationTests : AcceptanceTestsBase + { + [TestMethod] + [DeploymentItem(@"Data\Comment.csv", @"Data")] + [DeploymentItem(@"Data\Post.csv", @"Data")] + [DeploymentItem(@"Data\PostTagLink.csv", @"Data")] + [DeploymentItem(@"Data\Tag.csv", @"Data")] + [DeploymentItem(@"Data\User.csv", @"Data")] + public async Task GetPage2Post2() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitGet(effortConnection, "posts?page[number]=1&page[size]=2"); + + await AssertResponseContent(response, @"Fixtures\Pagination\GetAllResponsePaged-2-2.json", HttpStatusCode.OK); + } + } + + [TestMethod] + [DeploymentItem(@"Data\Comment.csv", @"Data")] + [DeploymentItem(@"Data\Post.csv", @"Data")] + [DeploymentItem(@"Data\PostTagLink.csv", @"Data")] + [DeploymentItem(@"Data\Tag.csv", @"Data")] + [DeploymentItem(@"Data\User.csv", @"Data")] + public async Task GetWithFilter() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitGet(effortConnection, "users?filter[last-name]=Burns&page[number]=1&page[size]=1"); + + await AssertResponseContent(response, @"Fixtures\Pagination\GetFilterPaged-2-1.json", HttpStatusCode.OK); + } + } + + + [TestMethod] + [DeploymentItem(@"Data\Comment.csv", @"Data")] + [DeploymentItem(@"Data\Post.csv", @"Data")] + [DeploymentItem(@"Data\PostTagLink.csv", @"Data")] + [DeploymentItem(@"Data\Tag.csv", @"Data")] + [DeploymentItem(@"Data\User.csv", @"Data")] + public async Task GetWithFilterSortedAscTest() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitGet(effortConnection, "users?filter[last-name]=Burns&page[number]=0&page[size]=2&sort=first-name"); + + await AssertResponseContent(response, @"Fixtures\Pagination\GetFilterPaged-1-2-sorted.json", HttpStatusCode.OK); + } + } + + + [TestMethod] + [DeploymentItem(@"Data\Comment.csv", @"Data")] + [DeploymentItem(@"Data\Post.csv", @"Data")] + [DeploymentItem(@"Data\PostTagLink.csv", @"Data")] + [DeploymentItem(@"Data\Tag.csv", @"Data")] + [DeploymentItem(@"Data\User.csv", @"Data")] + public async Task GetWithFilterSortedDescTest() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitGet(effortConnection, "users?filter[last-name]=Burns&page[number]=0&page[size]=2&sort=-first-name"); + + await AssertResponseContent(response, @"Fixtures\Pagination\GetFilterPaged-1-2-sorted-desc.json", HttpStatusCode.OK); + } + } + + } +} diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/UpdatingResourcesTests.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/UpdatingResourcesTests.cs index 9d898537..e24ae49b 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/UpdatingResourcesTests.cs +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/UpdatingResourcesTests.cs @@ -41,6 +41,81 @@ public async Task PatchWithAttributeUpdate() } } + [TestMethod] + [DeploymentItem(@"Data\Comment.csv", @"Data")] + [DeploymentItem(@"Data\Post.csv", @"Data")] + [DeploymentItem(@"Data\PostTagLink.csv", @"Data")] + [DeploymentItem(@"Data\Tag.csv", @"Data")] + [DeploymentItem(@"Data\User.csv", @"Data")] + public async Task PatchWithAttributeUpdateAndInclude() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitPatch(effortConnection, "posts/202?include=author", @"Fixtures\UpdatingResources\Requests\PatchWithAttributeUpdateRequest.json"); + + await AssertResponseContent(response, @"Fixtures\UpdatingResources\Responses\PatchWithAttributeUpdateWithIncludeResponse.json", HttpStatusCode.OK); + + using (var dbContext = new TestDbContext(effortConnection, false)) + { + var allPosts = dbContext.Posts.Include(p => p.Tags).ToArray(); + allPosts.Length.Should().Be(4); + var actualPost = allPosts.First(t => t.Id == "202"); + actualPost.Id.Should().Be("202"); + actualPost.Title.Should().Be("New post title"); + actualPost.Content.Should().Be("Post 2 content"); + actualPost.Created.Should().Be(new DateTimeOffset(2015, 02, 05, 08, 10, 0, new TimeSpan(0))); + actualPost.AuthorId.Should().Be("401"); + actualPost.Tags.Select(t => t.Id).Should().BeEquivalentTo("302", "303"); + } + } + } + + [TestMethod] + [DeploymentItem(@"Data\PostID.csv", @"Data")] + public async Task PatchWithAttributeUpdateID() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitPatch(effortConnection, "post-i-ds/202", @"Fixtures\UpdatingResources\Requests\PatchWithAttributeUpdateRequestID.json"); + + await AssertResponseContent(response, @"Fixtures\UpdatingResources\Responses\PatchWithAttributeUpdateResponseID.json", HttpStatusCode.OK); + + using (var dbContext = new TestDbContext(effortConnection, false)) + { + var allPosts = dbContext.PostsID; + allPosts.Count().Should().Be(4); + var actualPost = allPosts.First(t => t.ID == "202"); + actualPost.ID.Should().Be("202"); + actualPost.Title.Should().Be("New post title"); + actualPost.Content.Should().Be("Post 2 content"); + actualPost.Created.Should().Be(new DateTimeOffset(2015, 02, 05, 08, 10, 0, new TimeSpan(0))); + } + } + } + + [TestMethod] + [DeploymentItem(@"Data\PostLongId.csv", @"Data")] + public async Task PatchWithAttributeUpdateLongId() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitPatch(effortConnection, "post-long-ids/202", @"Fixtures\UpdatingResources\Requests\PatchWithAttributeUpdateRequestLongId.json"); + + await AssertResponseContent(response, @"Fixtures\UpdatingResources\Responses\PatchWithAttributeUpdateResponseLongId.json", HttpStatusCode.OK); + + using (var dbContext = new TestDbContext(effortConnection, false)) + { + var allPosts = dbContext.PostsLongId; + allPosts.Count().Should().Be(4); + var actualPost = allPosts.First(t => t.Id == 202); + actualPost.Id.Should().Be(202); + actualPost.Title.Should().Be("New post title"); + actualPost.Content.Should().Be("Post 2 content"); + actualPost.Created.Should().Be(new DateTimeOffset(2015, 02, 05, 08, 10, 0, new TimeSpan(0))); + } + } + } + [TestMethod] [DeploymentItem(@"Data\Comment.csv", @"Data")] [DeploymentItem(@"Data\Post.csv", @"Data")] diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/packages.config b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/packages.config index f704445b..0243217e 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/packages.config +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/packages.config @@ -3,9 +3,12 @@ + + + \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Controllers/SamplesController.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Controllers/SamplesController.cs index 66588415..4d6d7546 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Controllers/SamplesController.cs +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Controllers/SamplesController.cs @@ -1,6 +1,8 @@ using System; using System.Web.Http; using JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Models; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; namespace JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Controllers { @@ -44,7 +46,10 @@ public IHttpActionResult GetSamples() StringField = null, EnumField = default(SampleEnum), NullableEnumField = null, - ComplexAttributeField = null + ComplexAttributeField = null, + JTokenStringField = null, + JTokenObjectField = null, + JTokenArrayField = null }; var s2 = new Sample { @@ -82,10 +87,27 @@ public IHttpActionResult GetSamples() StringField = "Some string 156", EnumField = SampleEnum.Value1, NullableEnumField = SampleEnum.Value2, - ComplexAttributeField = "{\"foo\": { \"baz\": [11] }, \"bar\": 5}" + ComplexAttributeField = "{\"foo\": { \"baz\": [11] }, \"bar\": 5}", + JTokenStringField = "Andrew Jackson", + JTokenObjectField = JToken.FromObject(new SomeSerializableClass { MyField1 = "Abraham Lincoln", MyField2 = true, MyField3 = 439 }), + JTokenArrayField = new JArray( + JToken.FromObject(new SomeSerializableClass { MyField1 = "George Washington", MyField2 = null, MyField3 = 216 }), + JToken.FromObject(new SomeSerializableClass { MyField1 = "Thomas Jefferson", MyField2 = false, MyField3 = 631 })) }; return Ok(new[] { s1, s2 }); } + + [Serializable] + public class SomeSerializableClass + { + [JsonProperty("my-field1")] + public string MyField1 { get; set; } + + [JsonProperty("overridden-field2")] + public bool? MyField2 { get; set; } + + public int MyField3 { get; set; } + } } } \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/DocumentMaterializers/StarshipDocumentMaterializer.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/DocumentMaterializers/StarshipDocumentMaterializer.cs index 1f5ad325..9eb3fa47 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/DocumentMaterializers/StarshipDocumentMaterializer.cs +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/DocumentMaterializers/StarshipDocumentMaterializer.cs @@ -21,10 +21,11 @@ public StarshipDocumentMaterializer( TestDbContext dbContext, IQueryableResourceCollectionDocumentBuilder queryableResourceCollectionDocumentBuilder, IBaseUrlService baseUrlService, ISingleResourceDocumentBuilder singleResourceDocumentBuilder, + ISortExpressionExtractor sortExpressionExtractor, IQueryableEnumerationTransformer queryableEnumerationTransformer, IResourceTypeRegistry resourceTypeRegistry) : base( queryableResourceCollectionDocumentBuilder, baseUrlService, singleResourceDocumentBuilder, - queryableEnumerationTransformer, resourceTypeRegistry) + queryableEnumerationTransformer, sortExpressionExtractor, resourceTypeRegistry) { _dbContext = dbContext; } @@ -61,7 +62,7 @@ public override Task UpdateRecord(string id, ISingleRes throw new NotImplementedException(); } - public override Task DeleteRecord(string id, CancellationToken cancellationToken) + public override Task DeleteRecord(string id, HttpRequestMessage request, CancellationToken cancellationToken) { throw new NotImplementedException(); } diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/DocumentMaterializers/StarshipOfficersRelatedResourceMaterializer.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/DocumentMaterializers/StarshipOfficersRelatedResourceMaterializer.cs index 750a1faa..9fb646b3 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/DocumentMaterializers/StarshipOfficersRelatedResourceMaterializer.cs +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/DocumentMaterializers/StarshipOfficersRelatedResourceMaterializer.cs @@ -6,6 +6,7 @@ using JSONAPI.Core; using JSONAPI.Documents.Builders; using JSONAPI.EntityFramework.Http; +using JSONAPI.Http; namespace JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.DocumentMaterializers { @@ -15,8 +16,10 @@ public class StarshipOfficersRelatedResourceMaterializer : EntityFrameworkToMany public StarshipOfficersRelatedResourceMaterializer(ResourceTypeRelationship relationship, DbContext dbContext, IQueryableResourceCollectionDocumentBuilder queryableResourceCollectionDocumentBuilder, + ISortExpressionExtractor sortExpressionExtractor, + IIncludeExpressionExtractor includeExpressionExtractor, IResourceTypeRegistration primaryTypeRegistration) - : base(relationship, dbContext, queryableResourceCollectionDocumentBuilder, primaryTypeRegistration) + : base(relationship, dbContext, queryableResourceCollectionDocumentBuilder, sortExpressionExtractor, includeExpressionExtractor, primaryTypeRegistration) { _dbContext = dbContext; } diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.csproj b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.csproj index 26e9df66..0a5148ea 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.csproj +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.csproj @@ -1,212 +1,216 @@ - - - - - Debug - AnyCPU - - - 2.0 - {76DEE472-723B-4BE6-8B97-428AC326E30F} - {349c5851-65df-11da-9384-00065b846f21};{fae04ec0-301f-11d3-bf4b-00c04f79efbc} - Library - Properties - JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp - JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp - v4.5 - true - - - - - ..\ - true - - - true - full - false - bin\ - DEBUG;TRACE - prompt - 4 - - - pdbonly - true - bin\ - TRACE - prompt - 4 - - - - False - ..\packages\Autofac.3.5.2\lib\net40\Autofac.dll - - - False - ..\packages\Autofac.Owin.3.1.0\lib\net45\Autofac.Integration.Owin.dll - - - False - ..\packages\Autofac.WebApi2.3.4.0\lib\net45\Autofac.Integration.WebApi.dll - - - False - ..\packages\Autofac.WebApi2.Owin.3.2.0\lib\net45\Autofac.Integration.WebApi.Owin.dll - - - False - ..\packages\EntityFramework.6.1.3\lib\net45\EntityFramework.dll - - - False - ..\packages\EntityFramework.6.1.3\lib\net45\EntityFramework.SqlServer.dll - - - - ..\packages\Microsoft.Owin.3.0.0\lib\net45\Microsoft.Owin.dll - - - ..\packages\Microsoft.Owin.Host.SystemWeb.3.0.0\lib\net45\Microsoft.Owin.Host.SystemWeb.dll - - - False - ..\packages\Newtonsoft.Json.6.0.8\lib\net45\Newtonsoft.Json.dll - - - ..\packages\Owin.1.0\lib\net40\Owin.dll - - - - False - ..\packages\Microsoft.AspNet.WebApi.Client.5.2.2\lib\net45\System.Net.Http.Formatting.dll - - - - - - - - - - - - False - ..\packages\Microsoft.AspNet.WebApi.Core.5.2.2\lib\net45\System.Web.Http.dll - - - ..\packages\Microsoft.AspNet.WebApi.Owin.5.2.2\lib\net45\System.Web.Http.Owin.dll - - - - - - - - - - - - - Web.config - - - Web.config - - - - - Designer - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {64abe648-efcb-46ee-9e1a-e163f52bf372} - JSONAPI.Autofac.EntityFramework - - - {af7861f3-550b-4f70-a33e-1e5f48d39333} - JSONAPI.Autofac - - - {e906356c-93f6-41f6-9a0d-73b8a99aa53c} - JSONAPI.EntityFramework - - - {52b19fd6-efaa-45b5-9c3e-a652e27608d1} - JSONAPI - - - - 10.0 - $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) - - - - - - - - - True - True - 9283 - / - http://localhost:9283/ - False - False - - - False - - - - - - - - This project references NuGet package(s) that are missing on this computer. Enable NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. - - - - + + + + + Debug + AnyCPU + + + 2.0 + {76DEE472-723B-4BE6-8B97-428AC326E30F} + {349c5851-65df-11da-9384-00065b846f21};{fae04ec0-301f-11d3-bf4b-00c04f79efbc} + Library + Properties + JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp + JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp + v4.5 + true + + + + + ..\ + true + + + true + full + false + bin\ + DEBUG;TRACE + prompt + 4 + + + pdbonly + true + bin\ + TRACE + prompt + 4 + + + + False + ..\packages\Autofac.3.5.2\lib\net40\Autofac.dll + + + False + ..\packages\Autofac.Owin.3.1.0\lib\net45\Autofac.Integration.Owin.dll + + + False + ..\packages\Autofac.WebApi2.3.4.0\lib\net45\Autofac.Integration.WebApi.dll + + + False + ..\packages\Autofac.WebApi2.Owin.3.2.0\lib\net45\Autofac.Integration.WebApi.Owin.dll + + + False + ..\packages\EntityFramework.6.1.3\lib\net45\EntityFramework.dll + + + False + ..\packages\EntityFramework.6.1.3\lib\net45\EntityFramework.SqlServer.dll + + + + ..\packages\Microsoft.Owin.3.0.0\lib\net45\Microsoft.Owin.dll + + + ..\packages\Microsoft.Owin.Host.SystemWeb.3.0.0\lib\net45\Microsoft.Owin.Host.SystemWeb.dll + + + False + ..\packages\Newtonsoft.Json.6.0.8\lib\net45\Newtonsoft.Json.dll + + + ..\packages\Owin.1.0\lib\net40\Owin.dll + + + + False + ..\packages\Microsoft.AspNet.WebApi.Client.5.2.2\lib\net45\System.Net.Http.Formatting.dll + + + + + + + + + + + + False + ..\packages\Microsoft.AspNet.WebApi.Core.5.2.2\lib\net45\System.Web.Http.dll + + + ..\packages\Microsoft.AspNet.WebApi.Owin.5.2.2\lib\net45\System.Web.Http.Owin.dll + + + + + + + + + + + + + Web.config + + + Web.config + + + + + Designer + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {64abe648-efcb-46ee-9e1a-e163f52bf372} + JSONAPI.Autofac.EntityFramework + + + {af7861f3-550b-4f70-a33e-1e5f48d39333} + JSONAPI.Autofac + + + {e906356c-93f6-41f6-9a0d-73b8a99aa53c} + JSONAPI.EntityFramework + + + {52b19fd6-efaa-45b5-9c3e-a652e27608d1} + JSONAPI + + + + 10.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + + + + + + + True + True + 9283 + / + http://localhost:9283/ + False + False + + + False + + + + + + + + This project references NuGet package(s) that are missing on this computer. Enable NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. + + + + \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/Child.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/Child.cs new file mode 100644 index 00000000..b3aefcc7 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/Child.cs @@ -0,0 +1,21 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Models +{ + public class Child + { + public int Id { get; set; } + + public string ChildDescription { get; set; } + + [Required] + [JsonIgnore] + public int MasterId { get; set; } + + [ForeignKey("MasterId")] + public Master Master { get; set; } + } +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/Master.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/Master.cs new file mode 100644 index 00000000..5ad2015c --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/Master.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Web; + +namespace JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Models +{ + public class Master + { + public int Id { get; set; } + + public string Description { get; set; } + + public virtual ICollection Children { get; set; } + } +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/PostID.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/PostID.cs new file mode 100644 index 00000000..4e7bd60f --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/PostID.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Models +{ + public class PostID + { + [Key] + public string ID { get; set; } + + public string Title { get; set; } + + public string Content { get; set; } + + public DateTimeOffset Created { get; set; } + + } +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/PostLongId.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/PostLongId.cs new file mode 100644 index 00000000..24e830d5 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/PostLongId.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Models +{ + public class PostLongId + { + [Key] + public long Id { get; set; } + + public string Title { get; set; } + + public string Content { get; set; } + + public DateTimeOffset Created { get; set; } + + } +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/Sample.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/Sample.cs index c56ea671..e8096599 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/Sample.cs +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/Sample.cs @@ -1,5 +1,6 @@ using System; using JSONAPI.Attributes; +using Newtonsoft.Json.Linq; namespace JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Models { @@ -48,5 +49,11 @@ public class Sample [SerializeAsComplex] public string ComplexAttributeField { get; set; } + + public JToken JTokenStringField { get; set; } + + public JToken JTokenObjectField { get; set; } + + public JToken JTokenArrayField { get; set; } } } diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/TestDbContext.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/TestDbContext.cs index 7efbb549..4850d0a0 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/TestDbContext.cs +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/TestDbContext.cs @@ -40,6 +40,8 @@ protected override void OnModelCreating(DbModelBuilder modelBuilder) public DbSet Comments { get; set; } public DbSet Languages { get; set; } public DbSet Posts { get; set; } + public DbSet PostsID { get; set; } + public DbSet PostsLongId { get; set; } public DbSet Tags { get; set; } public DbSet Users { get; set; } public DbSet LanguageUserLinks { get; set; } @@ -47,5 +49,7 @@ protected override void OnModelCreating(DbModelBuilder modelBuilder) public DbSet StarshipClasses { get; set; } public DbSet Officers { get; set; } public DbSet StarshipOfficerLinks { get; set; } + public DbSet Masters { get; set; } + public DbSet Children { get; set; } } } \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Properties/AssemblyInfo.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Properties/AssemblyInfo.cs index 02097fc4..130abbb8 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Properties/AssemblyInfo.cs +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Properties/AssemblyInfo.cs @@ -1,4 +1,5 @@ using System.Reflection; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; // General Information about an assembly is controlled through the following @@ -32,3 +33,4 @@ // by using the '*' as shown below: [assembly: AssemblyVersion("1.0.0.0")] [assembly: AssemblyFileVersion("1.0.0.0")] +[assembly: InternalsVisibleTo("JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests")] \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Startup.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Startup.cs index ae14df36..678f8532 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Startup.cs +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Startup.cs @@ -13,6 +13,9 @@ using JSONAPI.EntityFramework; using JSONAPI.EntityFramework.Configuration; using Owin; +using System.Collections.Generic; +using JSONAPI.Documents.Builders; +using JSONAPI.EntityFramework.Documents.Builders; namespace JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp { @@ -33,7 +36,35 @@ public Startup(Func dbContextFactory) public void Configuration(IAppBuilder app) { - var configuration = new JsonApiConfiguration(); + /* these steps are divided in multiple methods to support better acceptance tests + * in production all the steps can be made inside the Configuration method. + */ + var configuration = BuildConfiguration(); + + var configurator = BuildAutofacConfigurator(app); + + var httpConfig = BuildHttpConfiguration(); + + MergeAndSetupConfiguration(app, configurator, httpConfig, configuration); + } + + internal void MergeAndSetupConfiguration(IAppBuilder app, JsonApiHttpAutofacConfigurator configurator, + HttpConfiguration httpConfig, JsonApiConfiguration configuration) + { + configurator.Apply(httpConfig, configuration); + app.UseWebApi(httpConfig); + app.UseAutofacWebApi(httpConfig); + } + + /// + /// Build up the which registers all the model types and their mappings. + /// + /// + internal JsonApiConfiguration BuildConfiguration() + { + var configuration = new JsonApiConfiguration( + new Core.PluralizationService( + new Dictionary { { "Child", "Children" } })); configuration.RegisterEntityFrameworkResourceType(); configuration.RegisterEntityFrameworkResourceType(); configuration.RegisterEntityFrameworkResourceType(); @@ -45,6 +76,8 @@ public void Configuration(IAppBuilder app) c.OverrideDefaultSortById(LanguageUserLinkSortByIdFactory); }); configuration.RegisterResourceType(); + configuration.RegisterResourceType(); + configuration.RegisterResourceType(); configuration.RegisterEntityFrameworkResourceType(); configuration.RegisterEntityFrameworkResourceType(); configuration.RegisterEntityFrameworkResourceType(); @@ -58,7 +91,18 @@ public void Configuration(IAppBuilder app) rc => rc.UseMaterializer()); }); // Example of a resource that is mapped from a DB entity configuration.RegisterResourceType(); + configuration.RegisterEntityFrameworkResourceType(); + configuration.RegisterEntityFrameworkResourceType(); + return configuration; + } + /// + /// Build up the which registers , modules and materializer + /// + /// + /// + internal JsonApiHttpAutofacConfigurator BuildAutofacConfigurator(IAppBuilder app) + { var configurator = new JsonApiHttpAutofacConfigurator(); configurator.OnApplicationLifetimeScopeCreating(builder => { @@ -70,13 +114,23 @@ public void Configuration(IAppBuilder app) builder.RegisterType() .As(); builder.RegisterApiControllers(Assembly.GetExecutingAssembly()); + builder.RegisterType().As(); + }); configurator.OnApplicationLifetimeScopeBegun(applicationLifetimeScope => { // TODO: is this a candidate for spinning into a JSONAPI.Autofac.WebApi.Owin package? Yuck app.UseAutofacMiddleware(applicationLifetimeScope); }); + return configurator; + } + /// + /// Build up the with additional routes + /// + /// + internal HttpConfiguration BuildHttpConfiguration() + { var httpConfig = new HttpConfiguration { IncludeErrorDetailPolicy = IncludeErrorDetailPolicy.Always @@ -86,12 +140,10 @@ public void Configuration(IAppBuilder app) httpConfig.Routes.MapHttpRoute("Samples", "samples", new { Controller = "Samples" }); httpConfig.Routes.MapHttpRoute("Search", "search", new { Controller = "Search" }); httpConfig.Routes.MapHttpRoute("Trees", "trees", new { Controller = "Trees" }); - - configurator.Apply(httpConfig, configuration); - app.UseWebApi(httpConfig); - app.UseAutofacWebApi(httpConfig); + return httpConfig; } + private BinaryExpression LanguageUserLinkFilterByIdFactory(ParameterExpression param, string id) { var split = id.Split('_'); diff --git a/JSONAPI.Autofac/JsonApiAutofacModule.cs b/JSONAPI.Autofac/JsonApiAutofacModule.cs index 69881ce1..dd248aa8 100644 --- a/JSONAPI.Autofac/JsonApiAutofacModule.cs +++ b/JSONAPI.Autofac/JsonApiAutofacModule.cs @@ -16,7 +16,7 @@ public class JsonApiAutofacModule : Module { private readonly IJsonApiConfiguration _jsonApiConfiguration; - internal JsonApiAutofacModule(IJsonApiConfiguration jsonApiConfiguration) + public JsonApiAutofacModule(IJsonApiConfiguration jsonApiConfiguration) { _jsonApiConfiguration = jsonApiConfiguration; } @@ -126,7 +126,14 @@ protected override void Load(ContainerBuilder builder) }); builder.RegisterType().SingleInstance(); - builder.RegisterType().As().SingleInstance(); + if (_jsonApiConfiguration.CustomBaseUrlService != null) + { + builder.Register(c => _jsonApiConfiguration.CustomBaseUrlService).As().SingleInstance(); + } + else + { + builder.RegisterType().As().SingleInstance(); + } builder.RegisterType().As().InstancePerRequest(); // Serialization @@ -157,6 +164,9 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().SingleInstance(); builder.RegisterType().As(); + // Misc + builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().SingleInstance(); } } } diff --git a/JSONAPI.Autofac/JsonApiHttpAutofacConfigurator.cs b/JSONAPI.Autofac/JsonApiHttpAutofacConfigurator.cs index 2fa79374..035f8c09 100644 --- a/JSONAPI.Autofac/JsonApiHttpAutofacConfigurator.cs +++ b/JSONAPI.Autofac/JsonApiHttpAutofacConfigurator.cs @@ -52,7 +52,7 @@ public void Apply(HttpConfiguration httpConfiguration, IJsonApiConfiguration jso _appLifetimeScopeBegunAction(applicationLifetimeScope); var jsonApiHttpConfiguration = applicationLifetimeScope.Resolve(); - jsonApiHttpConfiguration.Apply(httpConfiguration); + jsonApiHttpConfiguration.Apply(httpConfiguration, jsonApiConfiguration); httpConfiguration.DependencyResolver = new AutofacWebApiDependencyResolver(applicationLifetimeScope); } diff --git a/JSONAPI.EntityFramework.Tests/DbContextExtensionsTests.cs b/JSONAPI.EntityFramework.Tests/DbContextExtensionsTests.cs index 4b8d4ed2..8548b6bd 100644 --- a/JSONAPI.EntityFramework.Tests/DbContextExtensionsTests.cs +++ b/JSONAPI.EntityFramework.Tests/DbContextExtensionsTests.cs @@ -17,6 +17,7 @@ private class TestDbContext : DbContext { public DbSet Backlinks { get; set; } public DbSet Posts { get; set; } + public DbSet PostIDs { get; set; } public TestDbContext(DbConnection conn) : base(conn, true) { @@ -34,6 +35,10 @@ private class SubPost : Post { public string Foo { get; set; } } + private class SubPostID : PostID + { + public string Foo { get; set; } + } private DbConnection _conn; private TestDbContext _context; @@ -71,6 +76,17 @@ public void GetKeyNamesStandardIdTest() keyNames.First().Should().Be("Id"); } + [TestMethod] + public void GetKeyNamesStandardIDTest() + { + // Act + IEnumerable keyNames = _context.GetKeyNames(typeof(PostID)).ToArray(); + + // Assert + keyNames.Count().Should().Be(1); + keyNames.First().Should().Be("ID"); + } + [TestMethod] public void GetKeyNamesNonStandardIdTest() { @@ -93,6 +109,17 @@ public void GetKeyNamesForChildClass() keyNames.First().Should().Be("Id"); } + [TestMethod] + public void GetKeyNamesForChildClassID() + { + // Act + IEnumerable keyNames = _context.GetKeyNames(typeof(SubPostID)).ToArray(); + + // Assert + keyNames.Count().Should().Be(1); + keyNames.First().Should().Be("ID"); + } + [TestMethod] public void GetKeyNamesNotAnEntityTest() { diff --git a/JSONAPI.EntityFramework.Tests/JSONAPI.EntityFramework.Tests.csproj b/JSONAPI.EntityFramework.Tests/JSONAPI.EntityFramework.Tests.csproj index 2938cf78..6e341345 100644 --- a/JSONAPI.EntityFramework.Tests/JSONAPI.EntityFramework.Tests.csproj +++ b/JSONAPI.EntityFramework.Tests/JSONAPI.EntityFramework.Tests.csproj @@ -119,6 +119,7 @@ + diff --git a/JSONAPI.EntityFramework.Tests/Models/PostID.cs b/JSONAPI.EntityFramework.Tests/Models/PostID.cs new file mode 100644 index 00000000..420e047a --- /dev/null +++ b/JSONAPI.EntityFramework.Tests/Models/PostID.cs @@ -0,0 +1,21 @@ +namespace JSONAPI.EntityFramework.Tests.Models +{ + using System; + using System.Collections.Generic; + using System.ComponentModel.DataAnnotations.Schema; + + public partial class PostID + { + public PostID() + { + this.Comments = new HashSet(); + } + + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public Guid ID { get; set; } + public string Title { get; set; } + + public virtual Author Author { get; set; } + public virtual ICollection Comments { get; set; } + } +} diff --git a/JSONAPI.EntityFramework/Documents/Builders/EntityFrameworkQueryableResourceCollectionDocumentBuilder.cs b/JSONAPI.EntityFramework/Documents/Builders/EntityFrameworkQueryableResourceCollectionDocumentBuilder.cs new file mode 100644 index 00000000..1bd9e2c8 --- /dev/null +++ b/JSONAPI.EntityFramework/Documents/Builders/EntityFrameworkQueryableResourceCollectionDocumentBuilder.cs @@ -0,0 +1,55 @@ +using System; +using System.Data.Entity; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using JSONAPI.Documents; +using JSONAPI.Documents.Builders; +using JSONAPI.Http; +using JSONAPI.QueryableTransformers; + +namespace JSONAPI.EntityFramework.Documents.Builders +{ + /// + /// Provides a entity framework implementation of an IQueryableResourceCollectionDocumentBuilder + /// + public class EntityFrameworkQueryableResourceCollectionDocumentBuilder: DefaultQueryableResourceCollectionDocumentBuilder + { + /// + /// Creates a new EntityFrameworkQueryableResourceCollectionDocumentBuilder + /// + public EntityFrameworkQueryableResourceCollectionDocumentBuilder( + IResourceCollectionDocumentBuilder resourceCollectionDocumentBuilder, + IQueryableEnumerationTransformer enumerationTransformer, + IQueryableFilteringTransformer filteringTransformer, + IQueryableSortingTransformer sortingTransformer, + IQueryablePaginationTransformer paginationTransformer, + IBaseUrlService baseUrlService) : + base(resourceCollectionDocumentBuilder, + enumerationTransformer, + filteringTransformer, + sortingTransformer, + paginationTransformer, + baseUrlService) + { + } + + /// + /// Returns the metadata that should be sent with this document. + /// + protected override async Task GetDocumentMetadata(IQueryable originalQuery, IQueryable filteredQuery, IOrderedQueryable sortedQuery, + IPaginationTransformResult paginationResult, CancellationToken cancellationToken) + { + var metadata = new Metadata(); + if (paginationResult.PaginationWasApplied) + { + var count = await filteredQuery.CountAsync(cancellationToken); + metadata.MetaObject.Add("total-pages", (int)Math.Ceiling((decimal) count / paginationResult.PageSize)); + metadata.MetaObject.Add("total-count", count); + } + if (metadata.MetaObject.HasValues) + return metadata; + return null; + } + } +} diff --git a/JSONAPI.EntityFramework/EntityFrameworkResourceObjectMaterializer.cs b/JSONAPI.EntityFramework/EntityFrameworkResourceObjectMaterializer.cs index 57c2b29b..b0b26ea9 100644 --- a/JSONAPI.EntityFramework/EntityFrameworkResourceObjectMaterializer.cs +++ b/JSONAPI.EntityFramework/EntityFrameworkResourceObjectMaterializer.cs @@ -69,8 +69,10 @@ public async Task MaterializeResourceObject(IResourceObject resourceObje /// protected virtual Task SetIdForNewResource(IResourceObject resourceObject, object newObject, IResourceTypeRegistration typeRegistration) { - typeRegistration.IdProperty.SetValue(newObject, resourceObject.Id); - + if (resourceObject.Id != null) + { + typeRegistration.IdProperty.SetValue(newObject, Convert.ChangeType(resourceObject.Id, typeRegistration.IdProperty.PropertyType)); + } return Task.FromResult(0); } @@ -82,7 +84,7 @@ protected virtual async Task GetExistingRecord(IResourceTypeRegistration ResourceTypeRelationship[] relationshipsToInclude, CancellationToken cancellationToken) { var method = _openGetExistingRecordGenericMethod.MakeGenericMethod(registration.Type); - var result = (dynamic) method.Invoke(this, new object[] {registration, id, relationshipsToInclude, cancellationToken}); + var result = (dynamic) method.Invoke(this, new object[] {registration, id, relationshipsToInclude, cancellationToken}); // no convert needed => see GetExistingRecordGeneric => filterByIdFactory will do it return await result; } @@ -170,7 +172,7 @@ protected async Task GetExistingRecordGeneric(IResourceTypeReg string id, ResourceTypeRelationship[] relationshipsToInclude, CancellationToken cancellationToken) where TRecord : class { var param = Expression.Parameter(registration.Type); - var filterExpression = registration.GetFilterByIdExpression(param, id); + var filterExpression = registration.GetFilterByIdExpression(param, id); // no conversion of id => filterByIdFactory will do it var lambda = Expression.Lambda>(filterExpression, param); var query = _dbContext.Set().AsQueryable() .Where(lambda); diff --git a/JSONAPI.EntityFramework/Http/EntityFrameworkDocumentMaterializer.cs b/JSONAPI.EntityFramework/Http/EntityFrameworkDocumentMaterializer.cs index 844ce89a..114f7877 100644 --- a/JSONAPI.EntityFramework/Http/EntityFrameworkDocumentMaterializer.cs +++ b/JSONAPI.EntityFramework/Http/EntityFrameworkDocumentMaterializer.cs @@ -9,6 +9,7 @@ using JSONAPI.Core; using JSONAPI.Documents; using JSONAPI.Documents.Builders; +using JSONAPI.Extensions; using JSONAPI.Http; namespace JSONAPI.EntityFramework.Http @@ -18,11 +19,13 @@ namespace JSONAPI.EntityFramework.Http /// public class EntityFrameworkDocumentMaterializer : IDocumentMaterializer where T : class { - private readonly DbContext _dbContext; + protected readonly DbContext DbContext; private readonly IResourceTypeRegistration _resourceTypeRegistration; private readonly IQueryableResourceCollectionDocumentBuilder _queryableResourceCollectionDocumentBuilder; private readonly ISingleResourceDocumentBuilder _singleResourceDocumentBuilder; private readonly IEntityFrameworkResourceObjectMaterializer _entityFrameworkResourceObjectMaterializer; + private readonly ISortExpressionExtractor _sortExpressionExtractor; + private readonly IIncludeExpressionExtractor _includeExpressionExtractor; private readonly IBaseUrlService _baseUrlService; /// @@ -34,59 +37,72 @@ public EntityFrameworkDocumentMaterializer( IQueryableResourceCollectionDocumentBuilder queryableResourceCollectionDocumentBuilder, ISingleResourceDocumentBuilder singleResourceDocumentBuilder, IEntityFrameworkResourceObjectMaterializer entityFrameworkResourceObjectMaterializer, + ISortExpressionExtractor sortExpressionExtractor, + IIncludeExpressionExtractor includeExpressionExtractor, IBaseUrlService baseUrlService) { - _dbContext = dbContext; + DbContext = dbContext; _resourceTypeRegistration = resourceTypeRegistration; _queryableResourceCollectionDocumentBuilder = queryableResourceCollectionDocumentBuilder; _singleResourceDocumentBuilder = singleResourceDocumentBuilder; _entityFrameworkResourceObjectMaterializer = entityFrameworkResourceObjectMaterializer; + _sortExpressionExtractor = sortExpressionExtractor; + _includeExpressionExtractor = includeExpressionExtractor; _baseUrlService = baseUrlService; } public virtual Task GetRecords(HttpRequestMessage request, CancellationToken cancellationToken) { - var query = _dbContext.Set().AsQueryable(); - return _queryableResourceCollectionDocumentBuilder.BuildDocument(query, request, cancellationToken); + var sortExpressions = _sortExpressionExtractor.ExtractSortExpressions(request); + var includes = _includeExpressionExtractor.ExtractIncludeExpressions(request); + var query = QueryIncludeNavigationProperties(null, GetNavigationPropertiesIncludes(includes)); + return _queryableResourceCollectionDocumentBuilder.BuildDocument(query, request, sortExpressions, cancellationToken, includes); } public virtual async Task GetRecordById(string id, HttpRequestMessage request, CancellationToken cancellationToken) { var apiBaseUrl = GetBaseUrlFromRequest(request); - var singleResource = await FilterById(id, _resourceTypeRegistration).FirstOrDefaultAsync(cancellationToken); + var includes = _includeExpressionExtractor.ExtractIncludeExpressions(request); + var singleResource = await FilterById(id, _resourceTypeRegistration, GetNavigationPropertiesIncludes(includes)).FirstOrDefaultAsync(cancellationToken); if (singleResource == null) throw JsonApiException.CreateForNotFound(string.Format("No resource of type `{0}` exists with id `{1}`.", _resourceTypeRegistration.ResourceTypeName, id)); - return _singleResourceDocumentBuilder.BuildDocument(singleResource, apiBaseUrl, null, null); + return _singleResourceDocumentBuilder.BuildDocument(singleResource, apiBaseUrl, includes, null); } public virtual async Task CreateRecord(ISingleResourceDocument requestDocument, HttpRequestMessage request, CancellationToken cancellationToken) { var apiBaseUrl = GetBaseUrlFromRequest(request); - var newRecord = await MaterializeAsync(requestDocument.PrimaryData, cancellationToken); - await _dbContext.SaveChangesAsync(cancellationToken); - var returnDocument = _singleResourceDocumentBuilder.BuildDocument(newRecord, apiBaseUrl, null, null); + var newRecord = MaterializeAsync(requestDocument.PrimaryData, cancellationToken); + await OnCreate(newRecord); + await DbContext.SaveChangesAsync(cancellationToken); + var includes = _includeExpressionExtractor.ExtractIncludeExpressions(request); + var returnDocument = _singleResourceDocumentBuilder.BuildDocument(await newRecord, apiBaseUrl, includes, null); return returnDocument; } + public virtual async Task UpdateRecord(string id, ISingleResourceDocument requestDocument, HttpRequestMessage request, CancellationToken cancellationToken) { var apiBaseUrl = GetBaseUrlFromRequest(request); - var newRecord = await MaterializeAsync(requestDocument.PrimaryData, cancellationToken); - var returnDocument = _singleResourceDocumentBuilder.BuildDocument(newRecord, apiBaseUrl, null, null); - await _dbContext.SaveChangesAsync(cancellationToken); + var newRecord = MaterializeAsync(requestDocument.PrimaryData, cancellationToken); + await OnUpdate(newRecord); + await DbContext.SaveChangesAsync(cancellationToken); + var includes = _includeExpressionExtractor.ExtractIncludeExpressions(request); + var returnDocument = _singleResourceDocumentBuilder.BuildDocument(await newRecord, apiBaseUrl, includes, null); return returnDocument; } - public virtual async Task DeleteRecord(string id, CancellationToken cancellationToken) + public virtual async Task DeleteRecord(string id, HttpRequestMessage request, CancellationToken cancellationToken) { - var singleResource = await _dbContext.Set().FindAsync(cancellationToken, id); - _dbContext.Set().Remove(singleResource); - await _dbContext.SaveChangesAsync(cancellationToken); + var singleResource = DbContext.Set().FindAsync(cancellationToken, Convert.ChangeType(id, _resourceTypeRegistration.IdProperty.PropertyType)); + await OnDelete(singleResource); + DbContext.Set().Remove(await singleResource); + await DbContext.SaveChangesAsync(cancellationToken); return null; } @@ -103,58 +119,69 @@ protected string GetBaseUrlFromRequest(HttpRequestMessage request) /// Convert a resource object into a material record managed by EntityFramework. /// /// - protected virtual async Task MaterializeAsync(IResourceObject resourceObject, CancellationToken cancellationToken) + protected virtual async Task MaterializeAsync(IResourceObject resourceObject, CancellationToken cancellationToken) { - return await _entityFrameworkResourceObjectMaterializer.MaterializeResourceObject(resourceObject, cancellationToken); + return (T) await _entityFrameworkResourceObjectMaterializer.MaterializeResourceObject(resourceObject, cancellationToken); } + /// - /// Generic method for getting the related resources for a to-many relationship + /// Manipulate entity before create. /// - protected async Task GetRelatedToMany(string id, - ResourceTypeRelationship relationship, HttpRequestMessage request, CancellationToken cancellationToken) + /// + /// + protected virtual async Task OnCreate(Task record) { - var param = Expression.Parameter(typeof(T)); - var accessorExpr = Expression.Property(param, relationship.Property); - var lambda = Expression.Lambda>>(accessorExpr, param); - - var primaryEntityQuery = FilterById(id, _resourceTypeRegistration); - - // We have to see if the resource even exists, so we can throw a 404 if it doesn't - var relatedResource = await primaryEntityQuery.FirstOrDefaultAsync(cancellationToken); - if (relatedResource == null) - throw JsonApiException.CreateForNotFound(string.Format("No resource of type `{0}` exists with id `{1}`.", - _resourceTypeRegistration.ResourceTypeName, id)); - - var relatedResourceQuery = primaryEntityQuery.SelectMany(lambda); + await record; + } - return await _queryableResourceCollectionDocumentBuilder.BuildDocument(relatedResourceQuery, request, cancellationToken); + /// + /// Manipulate entity before update. + /// + /// + protected virtual async Task OnUpdate(Task record) + { + await record; } /// - /// Generic method for getting the related resources for a to-one relationship + /// Manipulate entity before delete. /// - protected async Task GetRelatedToOne(string id, - ResourceTypeRelationship relationship, HttpRequestMessage request, CancellationToken cancellationToken) + /// + /// + protected virtual async Task OnDelete(Task record) { - var param = Expression.Parameter(typeof(T)); - var accessorExpr = Expression.Property(param, relationship.Property); - var lambda = Expression.Lambda>(accessorExpr, param); + await record; + } - var primaryEntityQuery = FilterById(id, _resourceTypeRegistration); - var primaryEntityExists = await primaryEntityQuery.AnyAsync(cancellationToken); - if (!primaryEntityExists) - throw JsonApiException.CreateForNotFound(string.Format("No resource of type `{0}` exists with id `{1}`.", - _resourceTypeRegistration.ResourceTypeName, id)); - var relatedResource = await primaryEntityQuery.Select(lambda).FirstOrDefaultAsync(cancellationToken); - return _singleResourceDocumentBuilder.BuildDocument(relatedResource, GetBaseUrlFromRequest(request), null, null); + /// + /// This method allows to include into query. + /// This can reduce the number of queries (eager loading) + /// + /// + /// + /// + protected virtual Expression>[] GetNavigationPropertiesIncludes(string[] includes) + { + List>> list = new List>>(); + foreach (var include in includes) + { + var incl = include.Pascalize(); + var param = Expression.Parameter(typeof(TResource)); + var lambda = + Expression.Lambda>( + Expression.PropertyOrField(param, incl),param); + list.Add(lambda); + } + return list.ToArray(); } - private IQueryable Filter(Expression> predicate, + + private IQueryable QueryIncludeNavigationProperties(Expression> predicate, params Expression>[] includes) where TResource : class { - IQueryable query = _dbContext.Set(); - if (includes != null && includes.Any()) + IQueryable query = DbContext.Set(); + if (includes != null && includes.Any()) // eager loading query = includes.Aggregate(query, (current, include) => current.Include(include)); if (predicate != null) @@ -169,7 +196,7 @@ private IQueryable FilterById(string id, IResourceTypeRegi var param = Expression.Parameter(typeof(TResource)); var filterByIdExpression = resourceTypeRegistration.GetFilterByIdExpression(param, id); var predicate = Expression.Lambda>(filterByIdExpression, param); - return Filter(predicate, includes); + return QueryIncludeNavigationProperties(predicate, includes); } } } diff --git a/JSONAPI.EntityFramework/Http/EntityFrameworkToManyRelatedResourceDocumentMaterializer.cs b/JSONAPI.EntityFramework/Http/EntityFrameworkToManyRelatedResourceDocumentMaterializer.cs index e2078758..142075a0 100644 --- a/JSONAPI.EntityFramework/Http/EntityFrameworkToManyRelatedResourceDocumentMaterializer.cs +++ b/JSONAPI.EntityFramework/Http/EntityFrameworkToManyRelatedResourceDocumentMaterializer.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using JSONAPI.Core; using JSONAPI.Documents.Builders; +using JSONAPI.Extensions; using JSONAPI.Http; namespace JSONAPI.EntityFramework.Http @@ -28,8 +29,10 @@ public EntityFrameworkToManyRelatedResourceDocumentMaterializer( ResourceTypeRelationship relationship, DbContext dbContext, IQueryableResourceCollectionDocumentBuilder queryableResourceCollectionDocumentBuilder, + ISortExpressionExtractor sortExpressionExtractor, + IIncludeExpressionExtractor includeExpressionExtractor, IResourceTypeRegistration primaryTypeRegistration) - : base(queryableResourceCollectionDocumentBuilder) + : base(queryableResourceCollectionDocumentBuilder, sortExpressionExtractor, includeExpressionExtractor) { _relationship = relationship; _dbContext = dbContext; @@ -51,31 +54,45 @@ protected override async Task> GetRelatedQuery(string prima throw JsonApiException.CreateForNotFound(string.Format( "No resource of type `{0}` exists with id `{1}`.", _primaryTypeRegistration.ResourceTypeName, primaryResourceId)); + var includes = GetNavigationPropertiesIncludes(Includes); + var query = primaryEntityQuery.SelectMany(lambda); - return primaryEntityQuery.SelectMany(lambda); + if (includes != null && includes.Any()) + query = includes.Aggregate(query, (current, include) => current.Include(include)); + return query; + } + + + /// + /// This method allows to include into query. + /// This can reduce the number of queries (eager loading) + /// + /// + /// + protected virtual Expression>[] GetNavigationPropertiesIncludes(string[] includes) + { + List>> list = new List>>(); + foreach (var include in includes) + { + var incl = include.Pascalize(); + var param = Expression.Parameter(typeof(TRelated)); + var lambda = + Expression.Lambda>( + Expression.PropertyOrField(param, incl), param); + list.Add(lambda); + } + return list.ToArray(); } private IQueryable FilterById(string id, - IResourceTypeRegistration resourceTypeRegistration, - params Expression>[] includes) where TResource : class + IResourceTypeRegistration resourceTypeRegistration) where TResource : class { var param = Expression.Parameter(typeof (TResource)); var filterByIdExpression = resourceTypeRegistration.GetFilterByIdExpression(param, id); var predicate = Expression.Lambda>(filterByIdExpression, param); - return Filter(predicate, includes); - } - - private IQueryable Filter(Expression> predicate, - params Expression>[] includes) where TResource : class - { IQueryable query = _dbContext.Set(); - if (includes != null && includes.Any()) - query = includes.Aggregate(query, (current, include) => current.Include(include)); - - if (predicate != null) - query = query.Where(predicate); - - return query.AsQueryable(); + return query.Where(predicate); } + } } diff --git a/JSONAPI.EntityFramework/JSONAPI.EntityFramework.csproj b/JSONAPI.EntityFramework/JSONAPI.EntityFramework.csproj index 0f1dd5c8..2045b33d 100644 --- a/JSONAPI.EntityFramework/JSONAPI.EntityFramework.csproj +++ b/JSONAPI.EntityFramework/JSONAPI.EntityFramework.csproj @@ -72,6 +72,7 @@ + diff --git a/JSONAPI.Tests/ActionFilters/DefaultFilteringTransformerTests.cs b/JSONAPI.Tests/ActionFilters/DefaultFilteringTransformerTests.cs index bf59c926..6b458e2a 100644 --- a/JSONAPI.Tests/ActionFilters/DefaultFilteringTransformerTests.cs +++ b/JSONAPI.Tests/ActionFilters/DefaultFilteringTransformerTests.cs @@ -9,6 +9,7 @@ using FluentAssertions; using JSONAPI.ActionFilters; using JSONAPI.Core; +using JSONAPI.Documents.Builders; using JSONAPI.QueryableTransformers; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -558,6 +559,21 @@ private Dummy[] GetArray(string uri) #region String + [TestMethod] + public void Filters_by_matching_string_id_property() + { + var returnedArray = GetArray("http://api.example.com/dummies?filter[id]=100"); + returnedArray.Length.Should().Be(1); + returnedArray[0].Id.Should().Be("100"); + } + + [TestMethod] + public void Filters_by_missing_string_id_property() + { + var returnedArray = GetArray("http://api.example.com/dummies?filter[id]="); + returnedArray.Length.Should().Be(0); + } + [TestMethod] public void Filters_by_matching_string_property() { @@ -1147,7 +1163,7 @@ public void Filters_by_missing_nullable_double_property() public void Does_not_filter_unknown_type() { Action action = () => GetArray("http://api.example.com/dummies?filter[unknownTypeField]=asdfasd"); - action.ShouldThrow().Which.Response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + action.ShouldThrow().Which.Error.Status.Should().Be(HttpStatusCode.BadRequest); } #endregion diff --git a/JSONAPI.Tests/ActionFilters/DefaultSortingTransformerTests.cs b/JSONAPI.Tests/ActionFilters/DefaultSortingTransformerTests.cs index a2e7e4b3..4283b1eb 100644 --- a/JSONAPI.Tests/ActionFilters/DefaultSortingTransformerTests.cs +++ b/JSONAPI.Tests/ActionFilters/DefaultSortingTransformerTests.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Net.Http; using FluentAssertions; using JSONAPI.Core; using JSONAPI.Documents.Builders; @@ -22,56 +21,78 @@ private class Dummy public string FirstName { get; set; } public string LastName { get; set; } + + public DateTime BirthDate { get; set; } + } + + private class Dummy2 + { + public int Id { get; set; } + + public string Name { get; set; } } private IList _fixtures; private IQueryable _fixturesQuery; + private IList _fixtures2; + private IQueryable _fixtures2Query; [TestInitialize] public void SetupFixtures() { _fixtures = new List { - new Dummy { Id = "1", FirstName = "Thomas", LastName = "Paine" }, - new Dummy { Id = "2", FirstName = "Samuel", LastName = "Adams" }, - new Dummy { Id = "3", FirstName = "George", LastName = "Washington"}, - new Dummy { Id = "4", FirstName = "Thomas", LastName = "Jefferson" }, - new Dummy { Id = "5", FirstName = "Martha", LastName = "Washington"}, - new Dummy { Id = "6", FirstName = "Abraham", LastName = "Lincoln" }, - new Dummy { Id = "7", FirstName = "Andrew", LastName = "Jackson" }, - new Dummy { Id = "8", FirstName = "Andrew", LastName = "Johnson" }, - new Dummy { Id = "9", FirstName = "William", LastName = "Harrison" } + new Dummy {Id = "1", FirstName = "Thomas", LastName = "Paine", BirthDate = new DateTime(1737, 2, 9)}, + new Dummy {Id = "2", FirstName = "Samuel", LastName = "Adams", BirthDate = new DateTime(1722, 9, 27)}, + new Dummy {Id = "3", FirstName = "George", LastName = "Washington", BirthDate = new DateTime(1732, 2, 22)}, + new Dummy {Id = "4", FirstName = "Thomas", LastName = "Jefferson", BirthDate = new DateTime(1743, 4, 13)}, + new Dummy {Id = "5", FirstName = "Martha", LastName = "Washington", BirthDate = new DateTime(1731, 6, 13)}, + new Dummy {Id = "6", FirstName = "Abraham", LastName = "Lincoln", BirthDate = new DateTime(1809, 2, 12)}, + new Dummy {Id = "7", FirstName = "Andrew", LastName = "Jackson", BirthDate = new DateTime(1767, 3, 15)}, + new Dummy {Id = "8", FirstName = "Andrew", LastName = "Johnson", BirthDate = new DateTime(1808, 12, 29)}, + new Dummy {Id = "9", FirstName = "William", LastName = "Harrison", BirthDate = new DateTime(1773, 2, 9)} }; _fixturesQuery = _fixtures.AsQueryable(); + + _fixtures2 = new List + { + new Dummy2 {Id = 45, Name = "France"}, + new Dummy2 {Id = 52, Name = "Spain"}, + new Dummy2 {Id = 33, Name = "Mongolia"}, + }; + _fixtures2Query = _fixtures2.AsQueryable(); } private DefaultSortingTransformer GetTransformer() { - var pluralizationService = new PluralizationService(new Dictionary - { - {"Dummy", "Dummies"} - }); - var registrar = new ResourceTypeRegistrar(new DefaultNamingConventions(pluralizationService)); - var registration = registrar.BuildRegistration(typeof(Dummy)); + var registrar = new ResourceTypeRegistrar(new DefaultNamingConventions(new PluralizationService())); var registry = new ResourceTypeRegistry(); - registry.AddRegistration(registration); + registry.AddRegistration(registrar.BuildRegistration(typeof(Dummy), "dummies")); + registry.AddRegistration(registrar.BuildRegistration(typeof(Dummy2), "dummy2s")); return new DefaultSortingTransformer(registry); } - private Dummy[] GetArray(string uri) + private TFixture[] GetArray(string[] sortExpressions, IQueryable fixturesQuery) + { + return GetTransformer().Sort(fixturesQuery, sortExpressions).ToArray(); + } + + private Dummy[] GetDummyArray(string[] sortExpressions) + { + return GetArray(sortExpressions, _fixturesQuery); + } + + private Dummy2[] GetDummy2Array(string[] sortExpressions) { - var request = new HttpRequestMessage(HttpMethod.Get, uri); - return GetTransformer().Sort(_fixturesQuery, request).ToArray(); + return GetArray(sortExpressions, _fixtures2Query); } - private void RunTransformAndExpectFailure(string uri, string expectedMessage) + private void RunTransformAndExpectFailure(string[] sortExpressions, string expectedMessage) { Action action = () => { - var request = new HttpRequestMessage(HttpMethod.Get, uri); - // ReSharper disable once UnusedVariable - var result = GetTransformer().Sort(_fixturesQuery, request).ToArray(); + var result = GetTransformer().Sort(_fixturesQuery, sortExpressions).ToArray(); }; action.ShouldThrow().Which.Error.Detail.Should().Be(expectedMessage); } @@ -79,65 +100,88 @@ private void RunTransformAndExpectFailure(string uri, string expectedMessage) [TestMethod] public void Sorts_by_attribute_ascending() { - var array = GetArray("http://api.example.com/dummies?sort=first-name"); + var array = GetDummyArray(new [] { "first-name" }); array.Should().BeInAscendingOrder(d => d.FirstName); } [TestMethod] public void Sorts_by_attribute_descending() { - var array = GetArray("http://api.example.com/dummies?sort=-first-name"); + var array = GetDummyArray(new [] { "-first-name" }); array.Should().BeInDescendingOrder(d => d.FirstName); } [TestMethod] public void Sorts_by_two_ascending_attributes() { - var array = GetArray("http://api.example.com/dummies?sort=last-name,first-name"); + var array = GetDummyArray(new [] { "last-name", "first-name" }); array.Should().ContainInOrder(_fixtures.OrderBy(d => d.LastName + d.FirstName)); } [TestMethod] public void Sorts_by_two_descending_attributes() { - var array = GetArray("http://api.example.com/dummies?sort=-last-name,-first-name"); + var array = GetDummyArray(new [] { "-last-name", "-first-name" }); array.Should().ContainInOrder(_fixtures.OrderByDescending(d => d.LastName + d.FirstName)); } + [TestMethod] + public void Sorts_by_id_when_expressions_are_empty() + { + var array = GetDummyArray(new string[] { }); + array.Should().ContainInOrder(_fixtures.OrderBy(d => d.Id)); + } + [TestMethod] public void Returns_400_if_sort_argument_is_empty() { - RunTransformAndExpectFailure("http://api.example.com/dummies?sort=", "One of the sort expressions is empty."); + RunTransformAndExpectFailure(new[] { "" }, "One of the sort expressions is empty."); } [TestMethod] public void Returns_400_if_sort_argument_is_whitespace() { - RunTransformAndExpectFailure("http://api.example.com/dummies?sort= ", "One of the sort expressions is empty."); + RunTransformAndExpectFailure(new [] { " " }, "One of the sort expressions is empty."); } [TestMethod] public void Returns_400_if_sort_argument_is_empty_descending() { - RunTransformAndExpectFailure("http://api.example.com/dummies?sort=-", "One of the sort expressions is empty."); + RunTransformAndExpectFailure(new [] { "-" }, "One of the sort expressions is empty."); } [TestMethod] public void Returns_400_if_sort_argument_is_whitespace_descending() { - RunTransformAndExpectFailure("http://api.example.com/dummies?sort=- ", "One of the sort expressions is empty."); + RunTransformAndExpectFailure(new[] { "- " }, "One of the sort expressions is empty."); } [TestMethod] public void Returns_400_if_no_property_exists() { - RunTransformAndExpectFailure("http://api.example.com/dummies?sort=foobar", "The attribute \"foobar\" does not exist on type \"dummies\"."); + RunTransformAndExpectFailure(new[] { "foobar" }, + "The attribute \"foobar\" does not exist on type \"dummies\"."); } [TestMethod] public void Returns_400_if_the_same_property_is_specified_more_than_once() { - RunTransformAndExpectFailure("http://api.example.com/dummies?sort=last-name,last-name", "The attribute \"last-name\" was specified more than once."); + RunTransformAndExpectFailure(new[] { "last-name", "last-name" }, + "The attribute \"last-name\" was specified more than once."); + } + + [TestMethod] + public void Can_sort_by_DateTimeOffset() + { + var array = GetDummyArray(new [] { "birth-date" }); + array.Should().BeInAscendingOrder(d => d.BirthDate); + } + + [TestMethod] + public void Can_sort_by_resource_with_integer_key() + { + var array = GetDummy2Array(new [] { "name" }); + array.Should().BeInAscendingOrder(d => d.Name); } } } diff --git a/JSONAPI.Tests/Core/EnumAttributeValueConverterTests.cs b/JSONAPI.Tests/Core/EnumAttributeValueConverterTests.cs new file mode 100644 index 00000000..9f14283f --- /dev/null +++ b/JSONAPI.Tests/Core/EnumAttributeValueConverterTests.cs @@ -0,0 +1,71 @@ +using FluentAssertions; +using JSONAPI.Core; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json.Linq; + +namespace JSONAPI.Tests.Core +{ + [TestClass] + public class EnumAttributeValueConverterTests + { + public enum Int32Enum + { + Value1 = 1, + Value2 = 2, + Value3 = 3 + } + + public enum Int64Enum : long + { + Value1 = 1, + Value2 = 2, + Value3 = 3 + } + + private class Class1 + { + public Int32Enum Value { get; set; } + } + + private class Class2 + { + public Int64Enum Value { get; set; } + } + + [TestMethod] + public void GetValue_for_int32_enum() + { + // Arrange + var property = typeof (Class1).GetProperty("Value"); + var obj = new Class1 + { + Value = Int32Enum.Value1 + }; + + // Act + var converter = new EnumAttributeValueConverter(property, typeof(Int32Enum), false); + var actualValue = (JValue)converter.GetValue(obj); + + // Assert + actualValue.Value.Should().Be((long)1); + } + + [TestMethod] + public void GetValue_for_int64_enum() + { + // Arrange + var property = typeof(Class2).GetProperty("Value"); + var obj = new Class2 + { + Value = Int64Enum.Value1 + }; + + // Act + var converter = new EnumAttributeValueConverter(property, typeof(Int64Enum), false); + var actualValue = (JValue)converter.GetValue(obj); + + // Assert + actualValue.Value.Should().Be((long)1); + } + } +} diff --git a/JSONAPI.Tests/Core/PrimitiveTypeAttributeValueConverterTests.cs b/JSONAPI.Tests/Core/PrimitiveTypeAttributeValueConverterTests.cs new file mode 100644 index 00000000..eb7b53f2 --- /dev/null +++ b/JSONAPI.Tests/Core/PrimitiveTypeAttributeValueConverterTests.cs @@ -0,0 +1,52 @@ +using FluentAssertions; +using JSONAPI.Core; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json.Linq; + +namespace JSONAPI.Tests.Core +{ + [TestClass] + public class PrimitiveTypeAttributeValueConverterTests + { + private class Class1 + { + public int? NullableIntValue { get; set; } + } + + [TestMethod] + public void GetValue_for_null() + { + // Arrange + var property = typeof (Class1).GetProperty("NullableIntValue"); + var obj = new Class1 + { + NullableIntValue = null + }; + + // Act + var converter = new PrimitiveTypeAttributeValueConverter(property); + var actualValue = (JValue)converter.GetValue(obj); + + // Assert + ((object)actualValue).Should().Be(null); + } + + [TestMethod] + public void SetValue_for_null() + { + // Arrange + var property = typeof(Class1).GetProperty("NullableIntValue"); + var obj = new Class1 + { + NullableIntValue = 4 + }; + + // Act + var converter = new PrimitiveTypeAttributeValueConverter(property); + converter.SetValue(obj, JValue.CreateNull()); + + // Assert + obj.NullableIntValue.Should().Be(null); + } + } +} diff --git a/JSONAPI.Tests/Core/ResourceTypeRegistrarTests.cs b/JSONAPI.Tests/Core/ResourceTypeRegistrarTests.cs index 3c461c96..b1b242e9 100644 --- a/JSONAPI.Tests/Core/ResourceTypeRegistrarTests.cs +++ b/JSONAPI.Tests/Core/ResourceTypeRegistrarTests.cs @@ -187,25 +187,38 @@ private AttributeGrabBag InitializeGrabBag() private void AssertAttribute(IResourceTypeRegistration reg, string attributeName, JToken tokenToSet, TPropertyType expectedPropertyValue, TTokenType expectedTokenAfterSet, Func getPropertyFunc) + { + AssertAttributeHelper(reg, attributeName, tokenToSet, grabBag => + { + var propertyValueAfterSet = getPropertyFunc(grabBag); + propertyValueAfterSet.Should().Be(expectedPropertyValue); + }, token => + { + if (expectedTokenAfterSet == null) + token.Should().BeNull(); + else + { + var convertedTokenValue = token.Value(); + convertedTokenValue.Should().Be(expectedTokenAfterSet); + } + }); + } + + private void AssertAttributeHelper(IResourceTypeRegistration reg, string attributeName, + JToken tokenToSet, Action testPropertyValueAfterSet, + Action testTokenAfterSetAndGet) { var grabBag = InitializeGrabBag(); var field = reg.GetFieldByName(attributeName); - var attribute = (ResourceTypeAttribute) field; + var attribute = (ResourceTypeAttribute)field; attribute.JsonKey.Should().Be(attributeName); attribute.SetValue(grabBag, tokenToSet); - var propertyValueAfterSet = getPropertyFunc(grabBag); - propertyValueAfterSet.Should().Be(expectedPropertyValue); - + testPropertyValueAfterSet(grabBag); + var convertedToken = attribute.GetValue(grabBag); - if (expectedTokenAfterSet == null) - convertedToken.Should().BeNull(); - else - { - var convertedTokenValue = convertedToken.Value(); - convertedTokenValue.Should().Be(expectedTokenAfterSet); - } + testTokenAfterSetAndGet(convertedToken); } [TestMethod] @@ -636,8 +649,8 @@ public void BuildRegistration_sets_up_correct_attribute_for_DateTime_field() var reg = registrar.BuildRegistration(typeof(AttributeGrabBag)); // Assert - AssertAttribute(reg, "date-time-field", "1776-07-04", new DateTime(1776, 07, 04), "1776-07-04T00:00:00", g => g.DateTimeField); - AssertAttribute(reg, "date-time-field", "1776-07-04T00:00:00", new DateTime(1776, 07, 04), "1776-07-04T00:00:00", g => g.DateTimeField); + AssertAttribute(reg, "date-time-field", "1776-07-04", new DateTime(1776, 07, 04, 0, 0, 0, DateTimeKind.Utc), "1776-07-04T00:00:00", g => g.DateTimeField); + AssertAttribute(reg, "date-time-field", "1776-07-04T00:00:00", new DateTime(1776, 07, 04, 0, 0, 0, DateTimeKind.Utc), "1776-07-04T00:00:00", g => g.DateTimeField); AssertAttribute(reg, "date-time-field", null, new DateTime(), "0001-01-01T00:00:00", g => g.DateTimeField); } @@ -651,8 +664,8 @@ public void BuildRegistration_sets_up_correct_attribute_for_nullable_DateTime_fi var reg = registrar.BuildRegistration(typeof(AttributeGrabBag)); // Assert - AssertAttribute(reg, "nullable-date-time-field", "1776-07-04", new DateTime(1776, 07, 04), "1776-07-04T00:00:00", g => g.NullableDateTimeField); - AssertAttribute(reg, "nullable-date-time-field", "1776-07-04T00:00:00", new DateTime(1776, 07, 04), "1776-07-04T00:00:00", g => g.NullableDateTimeField); + AssertAttribute(reg, "nullable-date-time-field", "1776-07-04", new DateTime(1776, 07, 04, 0, 0, 0, DateTimeKind.Utc), "1776-07-04T00:00:00", g => g.NullableDateTimeField); + AssertAttribute(reg, "nullable-date-time-field", "1776-07-04T00:00:00", new DateTime(1776, 07, 04, 0, 0, 0, DateTimeKind.Utc), "1776-07-04T00:00:00", g => g.NullableDateTimeField); AssertAttribute(reg, "nullable-date-time-field", null, null, (DateTime?)null, g => g.NullableDateTimeField); } @@ -733,5 +746,68 @@ public void BuildRegistration_sets_up_correct_attribute_for_nullable_enum_field( AssertAttribute(reg, "nullable-enum-field", (int)SampleEnum.Value1, SampleEnum.Value1, (int)SampleEnum.Value1, g => g.NullableEnumField); AssertAttribute(reg, "nullable-enum-field", null, null, (SampleEnum?)null, g => g.NullableEnumField); } + + [TestMethod] + public void BuildRegistration_sets_up_correct_attribute_for_to_one_complex_field() + { + // Arrange + var registrar = new ResourceTypeRegistrar(new DefaultNamingConventions(new PluralizationService())); + + // Act + var reg = registrar.BuildRegistration(typeof(AttributeGrabBag)); + + // Assert + AssertAttributeHelper(reg, "to-one-complex-type-field", + new JObject { { "intProp", 32 }, { "StringProp", "qux" } }, + grabBag => + { + grabBag.ToOneComplexTypeField.Should().NotBeNull(); + grabBag.ToOneComplexTypeField.IntProp.Should().Be(32); + grabBag.ToOneComplexTypeField.StringProp.Should().Be("qux"); + }, + token => + { + ((int)token["intProp"]).Should().Be(32); + ((string)token["StringProp"]).Should().Be("qux"); + }); + AssertAttribute(reg, "to-one-complex-type-field", null, null, (SampleComplexType)null, g => g.ToOneComplexTypeField); + } + + [TestMethod] + public void BuildRegistration_sets_up_correct_attribute_for_to_many_complex_field() + { + // Arrange + var registrar = new ResourceTypeRegistrar(new DefaultNamingConventions(new PluralizationService())); + + // Act + var reg = registrar.BuildRegistration(typeof(AttributeGrabBag)); + + // Assert + AssertAttributeHelper(reg, "to-many-complex-type-field", + new JArray + { + new JObject { { "intProp", 49 }, { "StringProp", "blue" } }, + new JObject { { "intProp", 67 }, { "StringProp", "orange" } } + }, + grabBag => + { + var result = grabBag.ToManyComplexTypeField.ToArray(); + result.Length.Should().Be(2); + result[0].IntProp.Should().Be(49); + result[0].StringProp.Should().Be("blue"); + result[1].IntProp.Should().Be(67); + result[1].StringProp.Should().Be("orange"); + }, + token => + { + var jarray = (JArray) token; + jarray.Count.Should().Be(2); + ((int)jarray[0]["intProp"]).Should().Be(49); + ((string)jarray[0]["StringProp"]).Should().Be("blue"); + ((int)jarray[1]["intProp"]).Should().Be(67); + ((string)jarray[1]["StringProp"]).Should().Be("orange"); + }); + AssertAttribute(reg, "to-many-complex-type-field", null, null, (SampleComplexType[])null, g => g.ToManyComplexTypeField); + } } } diff --git a/JSONAPI.Tests/Documents/Builders/FallbackDocumentBuilderTests.cs b/JSONAPI.Tests/Documents/Builders/FallbackDocumentBuilderTests.cs index 0ecd1365..a6b9f14d 100644 --- a/JSONAPI.Tests/Documents/Builders/FallbackDocumentBuilderTests.cs +++ b/JSONAPI.Tests/Documents/Builders/FallbackDocumentBuilderTests.cs @@ -30,9 +30,10 @@ public async Task Creates_single_resource_document_for_registered_non_collection var objectContent = new Fruit { Id = "984", Name = "Kiwi" }; var mockDocument = new Mock(MockBehavior.Strict); + var includePathExpression = new string[] {}; var singleResourceDocumentBuilder = new Mock(MockBehavior.Strict); - singleResourceDocumentBuilder.Setup(b => b.BuildDocument(objectContent, It.IsAny(), null, null)).Returns(mockDocument.Object); + singleResourceDocumentBuilder.Setup(b => b.BuildDocument(objectContent, It.IsAny(), includePathExpression, null, null)).Returns(mockDocument.Object); var mockQueryableDocumentBuilder = new Mock(MockBehavior.Strict); var mockResourceCollectionDocumentBuilder = new Mock(MockBehavior.Strict); @@ -43,9 +44,15 @@ public async Task Creates_single_resource_document_for_registered_non_collection var mockBaseUrlService = new Mock(MockBehavior.Strict); mockBaseUrlService.Setup(s => s.GetBaseUrl(request)).Returns("https://www.example.com"); + var mockSortExpressionExtractor = new Mock(MockBehavior.Strict); + mockSortExpressionExtractor.Setup(e => e.ExtractSortExpressions(request)).Returns(new[] { "id " }); + + var mockIncludeExpressionExtractor = new Mock(MockBehavior.Strict); + mockIncludeExpressionExtractor.Setup(e => e.ExtractIncludeExpressions(request)).Returns(includePathExpression); + // Act var fallbackDocumentBuilder = new FallbackDocumentBuilder(singleResourceDocumentBuilder.Object, - mockQueryableDocumentBuilder.Object, mockResourceCollectionDocumentBuilder.Object, mockBaseUrlService.Object); + mockQueryableDocumentBuilder.Object, mockResourceCollectionDocumentBuilder.Object, mockSortExpressionExtractor.Object, mockIncludeExpressionExtractor.Object, mockBaseUrlService.Object); var resultDocument = await fallbackDocumentBuilder.BuildDocument(objectContent, request, cancellationTokenSource.Token); // Assert @@ -70,19 +77,29 @@ public async Task Creates_resource_collection_document_for_queryables() var mockBaseUrlService = new Mock(MockBehavior.Strict); mockBaseUrlService.Setup(s => s.GetBaseUrl(request)).Returns("https://www.example.com/"); + + var sortExpressions = new[] { "id" }; + + var includeExpressions = new string[] { }; var cancellationTokenSource = new CancellationTokenSource(); var mockQueryableDocumentBuilder = new Mock(MockBehavior.Strict); mockQueryableDocumentBuilder - .Setup(b => b.BuildDocument(items, request, cancellationTokenSource.Token, null)) + .Setup(b => b.BuildDocument(items, request, sortExpressions, cancellationTokenSource.Token, includeExpressions)) .Returns(Task.FromResult(mockDocument.Object)); var mockResourceCollectionDocumentBuilder = new Mock(MockBehavior.Strict); + var mockSortExpressionExtractor = new Mock(MockBehavior.Strict); + mockSortExpressionExtractor.Setup(e => e.ExtractSortExpressions(request)).Returns(sortExpressions); + + var mockIncludeExpressionExtractor = new Mock(MockBehavior.Strict); + mockIncludeExpressionExtractor.Setup(e => e.ExtractIncludeExpressions(request)).Returns(includeExpressions); + // Act var fallbackDocumentBuilder = new FallbackDocumentBuilder(singleResourceDocumentBuilder.Object, - mockQueryableDocumentBuilder.Object, mockResourceCollectionDocumentBuilder.Object, mockBaseUrlService.Object); + mockQueryableDocumentBuilder.Object, mockResourceCollectionDocumentBuilder.Object, mockSortExpressionExtractor.Object, mockIncludeExpressionExtractor.Object, mockBaseUrlService.Object); var resultDocument = await fallbackDocumentBuilder.BuildDocument(items, request, cancellationTokenSource.Token); // Assert @@ -113,12 +130,18 @@ public async Task Creates_resource_collection_document_for_non_queryable_enumera var mockQueryableDocumentBuilder = new Mock(MockBehavior.Strict); var mockResourceCollectionDocumentBuilder = new Mock(MockBehavior.Strict); mockResourceCollectionDocumentBuilder - .Setup(b => b.BuildDocument(items, "https://www.example.com/", It.IsAny(), It.IsAny())) + .Setup(b => b.BuildDocument(items, "https://www.example.com/", It.IsAny(), It.IsAny(), null)) .Returns(() => (mockDocument.Object)); + var mockSortExpressionExtractor = new Mock(MockBehavior.Strict); + mockSortExpressionExtractor.Setup(e => e.ExtractSortExpressions(request)).Returns(new[] { "id " }); + + var mockIncludeExpressionExtractor = new Mock(MockBehavior.Strict); + mockIncludeExpressionExtractor.Setup(e => e.ExtractIncludeExpressions(request)).Returns(new string[] { }); + // Act var fallbackDocumentBuilder = new FallbackDocumentBuilder(singleResourceDocumentBuilder.Object, - mockQueryableDocumentBuilder.Object, mockResourceCollectionDocumentBuilder.Object, mockBaseUrlService.Object); + mockQueryableDocumentBuilder.Object, mockResourceCollectionDocumentBuilder.Object, mockSortExpressionExtractor.Object, mockIncludeExpressionExtractor.Object, mockBaseUrlService.Object); var resultDocument = await fallbackDocumentBuilder.BuildDocument(items, request, cancellationTokenSource.Token); // Assert diff --git a/JSONAPI.Tests/Documents/Builders/RegistryDrivenSingleResourceDocumentBuilderTests.cs b/JSONAPI.Tests/Documents/Builders/RegistryDrivenSingleResourceDocumentBuilderTests.cs index ba130e34..f5afb353 100644 --- a/JSONAPI.Tests/Documents/Builders/RegistryDrivenSingleResourceDocumentBuilderTests.cs +++ b/JSONAPI.Tests/Documents/Builders/RegistryDrivenSingleResourceDocumentBuilderTests.cs @@ -176,7 +176,7 @@ public void Returns_correct_document_for_resource() // Act var documentBuilder = new RegistryDrivenSingleResourceDocumentBuilder(mockRegistry.Object, linkConventions); - var document = documentBuilder.BuildDocument(country, "http://www.example.com", new[] { "provinces.capital", "continent" }, metadata); + var document = documentBuilder.BuildDocument(country, "http://www.example.com", new[] { "provinces.capital", "continent" }, metadata, null); // Assert document.PrimaryData.Id.Should().Be("4"); @@ -258,7 +258,7 @@ public void Returns_correct_document_for_null_resource() // Act var documentBuilder = new RegistryDrivenSingleResourceDocumentBuilder(mockRegistry.Object, linkConventions); - var document = documentBuilder.BuildDocument(null, "http://www.example.com", null, null); + var document = documentBuilder.BuildDocument(null, "http://www.example.com", null, null, null); // Assert document.PrimaryData.Should().BeNull(); diff --git a/JSONAPI.Tests/Extensions/TypeExtensionsTests.cs b/JSONAPI.Tests/Extensions/TypeExtensionsTests.cs index 19b51b69..9d593abc 100644 --- a/JSONAPI.Tests/Extensions/TypeExtensionsTests.cs +++ b/JSONAPI.Tests/Extensions/TypeExtensionsTests.cs @@ -1,6 +1,7 @@ using System; using JSONAPI.Extensions; using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json.Linq; namespace JSONAPI.Tests.Extensions { @@ -46,6 +47,9 @@ public void CanWriteAsJsonApiAttributeTest() Assert.IsTrue(typeof(String).CanWriteAsJsonApiAttribute(), "CanWriteTypeAsAttribute returned wrong answer for String!"); Assert.IsTrue(typeof(TestEnum).CanWriteAsJsonApiAttribute(), "CanWriteTypeAsAttribute returned wrong answer for enum!"); Assert.IsTrue(typeof(TestEnum?).CanWriteAsJsonApiAttribute(), "CanWriteTypeAsAttribute returned wrong answer for nullable enum!"); + Assert.IsTrue(typeof(JToken).CanWriteAsJsonApiAttribute(), "CanWriteTypeAsAttribute returned wrong answer for JToken!"); + Assert.IsTrue(typeof(JObject).CanWriteAsJsonApiAttribute(), "CanWriteTypeAsAttribute returned wrong answer for JObject!"); + Assert.IsTrue(typeof(JArray).CanWriteAsJsonApiAttribute(), "CanWriteTypeAsAttribute returned wrong answer for Jarray!"); Assert.IsFalse(typeof(Object).CanWriteAsJsonApiAttribute(), "CanWriteTypeAsAttribute returned wrong answer for Object!"); } diff --git a/JSONAPI.Tests/Http/BaseUrlServiceTest.cs b/JSONAPI.Tests/Http/BaseUrlServiceTest.cs new file mode 100644 index 00000000..304ae60f --- /dev/null +++ b/JSONAPI.Tests/Http/BaseUrlServiceTest.cs @@ -0,0 +1,287 @@ +using System; +using System.Net.Http; +using FluentAssertions; +using JSONAPI.Http; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace JSONAPI.Tests.Http +{ + [TestClass] + public class BaseUrlServiceTest + { + [TestMethod] + public void BaseUrlRootTest() + { + // Arrange + const string uri = "http://api.example.com/dummies?sort=first-name"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + var baseUrlService = new BaseUrlService(); + + // Act + var baseUrl =baseUrlService.GetBaseUrl(request); + + // Assert + baseUrl.Should().BeEquivalentTo("http://api.example.com/"); + } + + [TestMethod] + public void BaseUrlOneLevelTest() + { + // Arrange + const string uri = "http://api.example.com/api/dummies?sort=first-name"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + var baseUrlService = new BaseUrlService("api"); + + // Act + var baseUrl = baseUrlService.GetBaseUrl(request); + + // Assert + baseUrl.Should().BeEquivalentTo("http://api.example.com/api/"); + } + + [TestMethod] + public void BaseUrlOneLevelSlashTest() + { + // Arrange + const string uri = "http://api.example.com/api/dummies?sort=first-name"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + var baseUrlService = new BaseUrlService("/api"); + + // Act + var baseUrl = baseUrlService.GetBaseUrl(request); + + // Assert + baseUrl.Should().BeEquivalentTo("http://api.example.com/api/"); + } + + [TestMethod] + public void BaseUrlOneLevelSlash2Test() + { + // Arrange + const string uri = "http://api.example.com/api/dummies?sort=first-name"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + var baseUrlService = new BaseUrlService("api/"); + + // Act + var baseUrl = baseUrlService.GetBaseUrl(request); + + // Assert + baseUrl.Should().BeEquivalentTo("http://api.example.com/api/"); + } + + [TestMethod] + public void BaseUrlTwoLevelTest() + { + // Arrange + const string uri = "http://api.example.com/api/superapi/dummies?sort=first-name"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + var baseUrlService = new BaseUrlService("api/superapi"); + + // Act + var baseUrl = baseUrlService.GetBaseUrl(request); + + // Assert + baseUrl.Should().BeEquivalentTo("http://api.example.com/api/superapi/"); + } + + [TestMethod] + public void BaseUrlTwoLevelSlashTest() + { + // Arrange + const string uri = "http://api.example.com/api/superapi/dummies?sort=first-name"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + var baseUrlService = new BaseUrlService("api/superapi/"); + + // Act + var baseUrl = baseUrlService.GetBaseUrl(request); + + // Assert + baseUrl.Should().BeEquivalentTo("http://api.example.com/api/superapi/"); + } + + [TestMethod] + public void BaseUrlTwoLevelSlash2Test() + { + // Arrange + const string uri = "http://api.example.com/api/superapi/dummies?sort=first-name"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + var baseUrlService = new BaseUrlService("/api/superapi/"); + + // Act + var baseUrl = baseUrlService.GetBaseUrl(request); + + // Assert + baseUrl.Should().BeEquivalentTo("http://api.example.com/api/superapi/"); + } + + [TestMethod] + public void BaseUrlConflictingNameTest() + { + // Arrange + const string uri = "http://api.example.com/api/superapi?sort=api-name"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + var baseUrlService = new BaseUrlService("api"); + + // Act + var baseUrl = baseUrlService.GetBaseUrl(request); + + // Assert + baseUrl.Should().BeEquivalentTo("http://api.example.com/api/"); + } + + + [TestMethod] + public void BaseUrlPublicOriginTest() + { + // Arrange + const string uri = "http://wwwhost123/dummies?sort=first-name"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + var baseUrlService = new BaseUrlService(new Uri("http://api.example.com/"), ""); + + // Act + var baseUrl = baseUrlService.GetBaseUrl(request); + + // Assert + baseUrl.Should().BeEquivalentTo("http://api.example.com/"); + } + + [TestMethod] + public void BaseUrlPublicOriginNoSlashTest() + { + // Arrange + const string uri = "http://wwwhost123/dummies?sort=first-name"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + var baseUrlService = new BaseUrlService(new Uri("http://api.example.com"), ""); + + // Act + var baseUrl = baseUrlService.GetBaseUrl(request); + + // Assert + baseUrl.Should().BeEquivalentTo("http://api.example.com/"); + } + + [TestMethod] + public void BaseUrlPublicOriginHttpsTest() + { + // Arrange + const string uri = "http://wwwhost123/dummies?sort=first-name"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + var baseUrlService = new BaseUrlService(new Uri("https://api.example.com/"), ""); + + // Act + var baseUrl = baseUrlService.GetBaseUrl(request); + + // Assert + baseUrl.Should().BeEquivalentTo("https://api.example.com/"); + } + + [TestMethod] + public void BaseUrlPublicOriginHttpsHighPortTest() + { + // Arrange + const string uri = "http://wwwhost123/dummies?sort=first-name"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + var baseUrlService = new BaseUrlService(new Uri("https://api.example.com:12443/"), ""); + + // Act + var baseUrl = baseUrlService.GetBaseUrl(request); + + // Assert + baseUrl.Should().BeEquivalentTo("https://api.example.com:12443/"); + } + + [TestMethod] + public void BaseUrlPublicOriginInternalPortTest() + { + // Arrange + const string uri = "http://wwwhost123:8080/dummies?sort=first-name"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + var baseUrlService = new BaseUrlService(new Uri("http://api.example.com/"), ""); + + // Act + var baseUrl = baseUrlService.GetBaseUrl(request); + + // Assert + baseUrl.Should().BeEquivalentTo("http://api.example.com/"); + } + + + + [TestMethod] + public void BaseUrlPublicOriginContextPathTest() + { + // Arrange + const string uri = "http://wwwhost123/api/dummies?sort=first-name"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + var baseUrlService = new BaseUrlService(new Uri("http://api.example.com/"), "api"); + + // Act + var baseUrl = baseUrlService.GetBaseUrl(request); + + // Assert + baseUrl.Should().BeEquivalentTo("http://api.example.com/api/"); + } + + [TestMethod] + public void BaseUrlPublicOriginNoSlashContextPathTest() + { + // Arrange + const string uri = "http://wwwhost123/api/dummies?sort=first-name"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + var baseUrlService = new BaseUrlService(new Uri("http://api.example.com"), "/api/"); + + // Act + var baseUrl = baseUrlService.GetBaseUrl(request); + + // Assert + baseUrl.Should().BeEquivalentTo("http://api.example.com/api/"); + } + + [TestMethod] + public void BaseUrlPublicOriginHttpsContextPathTest() + { + // Arrange + const string uri = "http://wwwhost123/api/dummies?sort=first-name"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + var baseUrlService = new BaseUrlService(new Uri("https://api.example.com/"), "/api"); + + // Act + var baseUrl = baseUrlService.GetBaseUrl(request); + + // Assert + baseUrl.Should().BeEquivalentTo("https://api.example.com/api/"); + } + + [TestMethod] + public void BaseUrlPublicOriginHttpsHighPortContextPathTest() + { + // Arrange + const string uri = "http://wwwhost123/api/dummies?sort=first-name"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + var baseUrlService = new BaseUrlService(new Uri("https://api.example.com:12443/"), "api"); + + // Act + var baseUrl = baseUrlService.GetBaseUrl(request); + + // Assert + baseUrl.Should().BeEquivalentTo("https://api.example.com:12443/api/"); + } + + [TestMethod] + public void BaseUrlPublicOriginInternalPortContextPathTest() + { + // Arrange + const string uri = "http://wwwhost123:8080/api/dummies?sort=first-name"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + var baseUrlService = new BaseUrlService(new Uri("http://api.example.com/"), "api"); + + // Act + var baseUrl = baseUrlService.GetBaseUrl(request); + + // Assert + baseUrl.Should().BeEquivalentTo("http://api.example.com/api/"); + } + + + } +} diff --git a/JSONAPI.Tests/Http/DefaultIncludeExpressionExtractorTests.cs b/JSONAPI.Tests/Http/DefaultIncludeExpressionExtractorTests.cs new file mode 100644 index 00000000..0fb65380 --- /dev/null +++ b/JSONAPI.Tests/Http/DefaultIncludeExpressionExtractorTests.cs @@ -0,0 +1,57 @@ +using System.Net.Http; +using FluentAssertions; +using JSONAPI.Http; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace JSONAPI.Tests.Http +{ + [TestClass] + public class DefaultIncludeExpressionExtractorTests + { + [TestMethod] + public void ExtractsSingleIncludeExpressionFromUri() + { + // Arrange + const string uri = "http://api.example.com/dummies?include=boss"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + + // Act + var extractor = new DefaultIncludeExpressionExtractor(); + var inclExpressions = extractor.ExtractIncludeExpressions(request); + + // Assert + inclExpressions.Should().BeEquivalentTo("boss"); + } + + + [TestMethod] + public void ExtractsMultipleIncludeExpressionsFromUri() + { + // Arrange + const string uri = "http://api.example.com/dummies?include=boss,office-address"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + + // Act + var extractor = new DefaultIncludeExpressionExtractor(); + var inclExpressions = extractor.ExtractIncludeExpressions(request); + + // Assert + inclExpressions.Should().BeEquivalentTo("boss", "office-address"); + } + + [TestMethod] + public void ExtractsNothingWhenThereIsNoIncludeParam() + { + // Arrange + const string uri = "http://api.example.com/dummies"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + + // Act + var extractor = new DefaultIncludeExpressionExtractor(); + var inclExpression = extractor.ExtractIncludeExpressions(request); + + // Assert + inclExpression.Length.Should().Be(0); + } + } +} diff --git a/JSONAPI.Tests/Http/DefaultSortExpressionExtractorTests.cs b/JSONAPI.Tests/Http/DefaultSortExpressionExtractorTests.cs new file mode 100644 index 00000000..85b6003b --- /dev/null +++ b/JSONAPI.Tests/Http/DefaultSortExpressionExtractorTests.cs @@ -0,0 +1,86 @@ +using System.Net.Http; +using FluentAssertions; +using JSONAPI.Http; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace JSONAPI.Tests.Http +{ + [TestClass] + public class DefaultSortExpressionExtractorTests + { + [TestMethod] + public void ExtractsSingleSortExpressionFromUri() + { + // Arrange + const string uri = "http://api.example.com/dummies?sort=first-name"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + + // Act + var extractor = new DefaultSortExpressionExtractor(); + var sortExpressions = extractor.ExtractSortExpressions(request); + + // Assert + sortExpressions.Should().BeEquivalentTo("first-name"); + } + + [TestMethod] + public void ExtractsSingleDescendingSortExpressionFromUri() + { + // Arrange + const string uri = "http://api.example.com/dummies?sort=-first-name"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + + // Act + var extractor = new DefaultSortExpressionExtractor(); + var sortExpressions = extractor.ExtractSortExpressions(request); + + // Assert + sortExpressions.Should().BeEquivalentTo("-first-name"); + } + + [TestMethod] + public void ExtractsMultipleSortExpressionsFromUri() + { + // Arrange + const string uri = "http://api.example.com/dummies?sort=last-name,first-name"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + + // Act + var extractor = new DefaultSortExpressionExtractor(); + var sortExpressions = extractor.ExtractSortExpressions(request); + + // Assert + sortExpressions.Should().BeEquivalentTo("last-name", "first-name"); + } + + [TestMethod] + public void ExtractsMultipleSortExpressionsFromUriWithDifferentDirections() + { + // Arrange + const string uri = "http://api.example.com/dummies?sort=last-name,-first-name"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + + // Act + var extractor = new DefaultSortExpressionExtractor(); + var sortExpressions = extractor.ExtractSortExpressions(request); + + // Assert + sortExpressions.Should().BeEquivalentTo("last-name", "-first-name"); + } + + [TestMethod] + public void ExtractsNothingWhenThereIsNoSortParam() + { + // Arrange + const string uri = "http://api.example.com/dummies"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + + // Act + var extractor = new DefaultSortExpressionExtractor(); + var sortExpressions = extractor.ExtractSortExpressions(request); + + // Assert + sortExpressions.Length.Should().Be(0); + } + } +} diff --git a/JSONAPI.Tests/JSONAPI.Tests.csproj b/JSONAPI.Tests/JSONAPI.Tests.csproj index 53498a51..7f487003 100644 --- a/JSONAPI.Tests/JSONAPI.Tests.csproj +++ b/JSONAPI.Tests/JSONAPI.Tests.csproj @@ -85,9 +85,14 @@ + + + + + diff --git a/JSONAPI.Tests/Json/Fixtures/ResourceCollectionDocumentFormatter/Serialize_ResourceCollectionDocument_for_all_possible_members.json b/JSONAPI.Tests/Json/Fixtures/ResourceCollectionDocumentFormatter/Serialize_ResourceCollectionDocument_for_all_possible_members.json index 9eb2c810..fad9ad33 100644 --- a/JSONAPI.Tests/Json/Fixtures/ResourceCollectionDocumentFormatter/Serialize_ResourceCollectionDocument_for_all_possible_members.json +++ b/JSONAPI.Tests/Json/Fixtures/ResourceCollectionDocumentFormatter/Serialize_ResourceCollectionDocument_for_all_possible_members.json @@ -1,12 +1,12 @@ { - "data": [ - "Primary data 1", - "Primary data 2" - ], - "included": [ - "Related data object 1", - "Related data object 2", - "Related data object 3" - ], - "meta": "Placeholder metadata object" + "meta": "Placeholder metadata object", + "data": [ + "Primary data 1", + "Primary data 2" + ], + "included": [ + "Related data object 1", + "Related data object 2", + "Related data object 3" + ] } \ No newline at end of file diff --git a/JSONAPI.Tests/Json/Fixtures/ResourceCollectionDocumentFormatter/Serialize_ResourceCollectionDocument_for_primary_data_only_and_metadata.json b/JSONAPI.Tests/Json/Fixtures/ResourceCollectionDocumentFormatter/Serialize_ResourceCollectionDocument_for_primary_data_only_and_metadata.json index ef98daf7..6d770373 100644 --- a/JSONAPI.Tests/Json/Fixtures/ResourceCollectionDocumentFormatter/Serialize_ResourceCollectionDocument_for_primary_data_only_and_metadata.json +++ b/JSONAPI.Tests/Json/Fixtures/ResourceCollectionDocumentFormatter/Serialize_ResourceCollectionDocument_for_primary_data_only_and_metadata.json @@ -1,7 +1,7 @@ { - "data": [ - "Primary data 1", - "Primary data 2" - ], - "meta": "Placeholder metadata object" + "meta": "Placeholder metadata object", + "data": [ + "Primary data 1", + "Primary data 2" + ] } \ No newline at end of file diff --git a/JSONAPI.Tests/Models/AttributeGrabBag.cs b/JSONAPI.Tests/Models/AttributeGrabBag.cs index 85490cef..dca40e34 100644 --- a/JSONAPI.Tests/Models/AttributeGrabBag.cs +++ b/JSONAPI.Tests/Models/AttributeGrabBag.cs @@ -1,5 +1,8 @@ using System; +using System.Collections.Generic; using JSONAPI.Attributes; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; namespace JSONAPI.Tests.Models { @@ -9,6 +12,13 @@ public enum SampleEnum Value2 = 2 } + public class SampleComplexType + { + [JsonProperty("intProp")] + public Int32 IntProp { get; set; } + public string StringProp { get; set; } + } + public class AttributeGrabBag { public string Id { get; set; } @@ -48,5 +58,11 @@ public class AttributeGrabBag [SerializeAsComplex] public string ComplexAttributeField { get; set; } + + [SerializeAsComplex] + public SampleComplexType ToOneComplexTypeField { get; set; } + + [SerializeAsComplex] + public virtual ICollection ToManyComplexTypeField { get; set; } } } diff --git a/JSONAPI/Attributes/LinkSettingsAttribute.cs b/JSONAPI/Attributes/LinkSettingsAttribute.cs new file mode 100644 index 00000000..4c2b96cf --- /dev/null +++ b/JSONAPI/Attributes/LinkSettingsAttribute.cs @@ -0,0 +1,25 @@ +using System; + +namespace JSONAPI.Attributes +{ + /// + /// This attribute should be added to a property to override DefaultLinkConventions's default + /// behavior for serializing links. + /// + [AttributeUsage(AttributeTargets.Property)] + public class LinkSettingsAttribute : Attribute + { + internal bool SerializeRelationshipLink; + + internal bool SerializeRelatedResourceLink; + + /// + /// Creates a new LinkSettingsAttribute. + /// + public LinkSettingsAttribute(bool serializeRelationshipLink = true, bool serializeRelatedResourceLink = true) + { + SerializeRelationshipLink = serializeRelationshipLink; + SerializeRelatedResourceLink = serializeRelatedResourceLink; + } + } +} diff --git a/JSONAPI/Configuration/JsonApiConfiguration.cs b/JSONAPI/Configuration/JsonApiConfiguration.cs index 22722ba4..27752c1a 100644 --- a/JSONAPI/Configuration/JsonApiConfiguration.cs +++ b/JSONAPI/Configuration/JsonApiConfiguration.cs @@ -14,6 +14,7 @@ public class JsonApiConfiguration : IJsonApiConfiguration private readonly IResourceTypeRegistrar _resourceTypeRegistrar; public ILinkConventions LinkConventions { get; private set; } public IEnumerable ResourceTypeConfigurations { get { return _resourceTypeConfigurations; } } + public IBaseUrlService CustomBaseUrlService { get; set; } private readonly IList _resourceTypeConfigurations; @@ -104,5 +105,10 @@ public interface IJsonApiConfiguration /// by the ResourceTypeRegistrar /// IEnumerable ResourceTypeConfigurations { get; } + + /// + /// A custom configured + /// + IBaseUrlService CustomBaseUrlService { get; } } } diff --git a/JSONAPI/Configuration/JsonApiHttpConfiguration.cs b/JSONAPI/Configuration/JsonApiHttpConfiguration.cs index 32ce73b1..4451eda8 100644 --- a/JSONAPI/Configuration/JsonApiHttpConfiguration.cs +++ b/JSONAPI/Configuration/JsonApiHttpConfiguration.cs @@ -34,7 +34,8 @@ public JsonApiHttpConfiguration(JsonApiFormatter formatter, /// Applies the running configuration to an HttpConfiguration instance /// /// The HttpConfiguration to apply this JsonApiHttpConfiguration to - public void Apply(HttpConfiguration httpConfig) + /// configuration holding BaseUrlService wich could provide a context path. + public void Apply(HttpConfiguration httpConfig, IJsonApiConfiguration jsonApiConfiguration) { httpConfig.Formatters.Clear(); httpConfig.Formatters.Add(_formatter); @@ -42,10 +43,16 @@ public void Apply(HttpConfiguration httpConfig) httpConfig.Filters.Add(_fallbackDocumentBuilderAttribute); httpConfig.Filters.Add(_jsonApiExceptionFilterAttribute); + var contextPath = jsonApiConfiguration.CustomBaseUrlService?.GetContextPath(); + if (contextPath != null && !contextPath.Equals(string.Empty)) + { + contextPath += "/"; + } + // Web API routes - httpConfig.Routes.MapHttpRoute("ResourceCollection", "{resourceType}", new { controller = "Main" }); - httpConfig.Routes.MapHttpRoute("Resource", "{resourceType}/{id}", new { controller = "Main" }); - httpConfig.Routes.MapHttpRoute("RelatedResource", "{resourceType}/{id}/{relationshipName}", new { controller = "Main" }); + httpConfig.Routes.MapHttpRoute("ResourceCollection", contextPath + "{resourceType}", new { controller = "Main" }); + httpConfig.Routes.MapHttpRoute("Resource", contextPath + "{resourceType}/{id}", new { controller = "Main" }); + httpConfig.Routes.MapHttpRoute("RelatedResource", contextPath + "{resourceType}/{id}/{relationshipName}", new { controller = "Main" }); } } } diff --git a/JSONAPI/Core/ComplexAttributeValueConverter.cs b/JSONAPI/Core/ComplexAttributeValueConverter.cs index ab892c97..e26726d0 100644 --- a/JSONAPI/Core/ComplexAttributeValueConverter.cs +++ b/JSONAPI/Core/ComplexAttributeValueConverter.cs @@ -5,7 +5,7 @@ namespace JSONAPI.Core { /// /// Implementation of suitable for - /// use with complex attributes. + /// use with complex attributes that deserialize to strings. /// public class ComplexAttributeValueConverter : IAttributeValueConverter { diff --git a/JSONAPI/Core/DateTimeAttributeValueConverter.cs b/JSONAPI/Core/DateTimeAttributeValueConverter.cs index ca3515ff..94550d07 100644 --- a/JSONAPI/Core/DateTimeAttributeValueConverter.cs +++ b/JSONAPI/Core/DateTimeAttributeValueConverter.cs @@ -41,6 +41,7 @@ public void SetValue(object resource, JToken value) else { var dateTimeValue = value.Value(); + if (dateTimeValue.Kind == DateTimeKind.Local) dateTimeValue = dateTimeValue.ToUniversalTime(); _property.SetValue(resource, dateTimeValue); } } diff --git a/JSONAPI/Core/DecimalAttributeValueConverter.cs b/JSONAPI/Core/DecimalAttributeValueConverter.cs index 064572bc..c15c7477 100644 --- a/JSONAPI/Core/DecimalAttributeValueConverter.cs +++ b/JSONAPI/Core/DecimalAttributeValueConverter.cs @@ -39,7 +39,7 @@ public JToken GetValue(object resource) public void SetValue(object resource, JToken value) { - if (value == null) + if (value == null || value.Type == JTokenType.Null) _property.SetValue(resource, null); else { diff --git a/JSONAPI/Core/DefaultEphemeralRelatedResourceCreator.cs b/JSONAPI/Core/DefaultEphemeralRelatedResourceCreator.cs new file mode 100644 index 00000000..7eb6f419 --- /dev/null +++ b/JSONAPI/Core/DefaultEphemeralRelatedResourceCreator.cs @@ -0,0 +1,18 @@ +using System; + +namespace JSONAPI.Core +{ + /// + /// Default implementation of , using Activator + /// + public class DefaultEphemeralRelatedResourceCreator : IEphemeralRelatedResourceCreator + { + /// + public object CreateEphemeralResource(IResourceTypeRegistration resourceTypeRegistration, string id) + { + var obj = Activator.CreateInstance(resourceTypeRegistration.Type); + resourceTypeRegistration.SetIdForResource(obj, id); + return obj; + } + } +} \ No newline at end of file diff --git a/JSONAPI/Core/EnumAttributeValueConverter.cs b/JSONAPI/Core/EnumAttributeValueConverter.cs index df9a9599..fd893adc 100644 --- a/JSONAPI/Core/EnumAttributeValueConverter.cs +++ b/JSONAPI/Core/EnumAttributeValueConverter.cs @@ -30,7 +30,7 @@ public EnumAttributeValueConverter(PropertyInfo property, Type enumType, bool is public JToken GetValue(object resource) { var value = _property.GetValue(resource); - if (value != null) return (int) value; + if (value != null) return Convert.ToInt64(value); if (_isNullable) return null; return 0; } diff --git a/JSONAPI/Core/EphemeralRelatedResourceReader.cs b/JSONAPI/Core/EphemeralRelatedResourceReader.cs new file mode 100644 index 00000000..7f40ef7f --- /dev/null +++ b/JSONAPI/Core/EphemeralRelatedResourceReader.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using JSONAPI.Documents; +using JSONAPI.Json; + +namespace JSONAPI.Core +{ + /// + /// Populates property values on an ephemeral resource + /// + /// + [Obsolete] + public class EphemeralRelatedResourceReader : IEphemeralRelatedResourceReader + { + private readonly IEphemeralRelatedResourceReader _ephemeralRelatedResourceReader; + + /// + /// Creates a new EphemeralRelatedResourceReader + /// + /// + public EphemeralRelatedResourceReader(IEphemeralRelatedResourceReader ephemeralRelatedResourceReader) + { + _ephemeralRelatedResourceReader = ephemeralRelatedResourceReader; + } + + public void SetProperty(T ephemeralResource, string jsonKey, IRelationshipObject relationshipObject) + { + _ephemeralRelatedResourceReader.SetProperty(ephemeralResource, jsonKey, relationshipObject); + } + } + + /// + /// Populates property values on an ephemeral resource + /// + /// + public class EphemeralRelatedResourceReader : IEphemeralRelatedResourceReader + { + private readonly IResourceTypeRegistry _resourceTypeRegistry; + private readonly IEphemeralRelatedResourceCreator _ephemeralRelatedResourceCreator; + private readonly MethodInfo _openSetToManyRelationshipValueMethod; + + /// + /// Creates a new EphemeralRelatedResourceReader + /// + /// + /// + public EphemeralRelatedResourceReader(IResourceTypeRegistry resourceTypeRegistry, IEphemeralRelatedResourceCreator ephemeralRelatedResourceCreator) + { + _resourceTypeRegistry = resourceTypeRegistry; + _ephemeralRelatedResourceCreator = ephemeralRelatedResourceCreator; + _openSetToManyRelationshipValueMethod = GetType() + .GetMethod("SetToManyRelationshipValue", BindingFlags.NonPublic | BindingFlags.Instance); + } + + public void SetProperty(T ephemeralResource, string jsonKey, IRelationshipObject relationshipObject) + { + var resourceTypeRegistration = new Lazy(() => _resourceTypeRegistry.GetRegistrationForType(typeof(T))); + var relationship = resourceTypeRegistration.Value.GetFieldByName(jsonKey) as ResourceTypeRelationship; + if (relationship == null) return; + + if (relationship.IsToMany) + SetPropertyForToManyRelationship(ephemeralResource, relationship, relationshipObject.Linkage); + else + SetPropertyForToOneRelationship(ephemeralResource, relationship, relationshipObject.Linkage); + } + + protected virtual void SetPropertyForToOneRelationship(T ephemeralResource, ResourceTypeRelationship relationship, IResourceLinkage linkage) + { + if (linkage == null) + throw new DeserializationException("Missing linkage for to-one relationship", + "Expected an object for to-one linkage, but no linkage was specified.", $"/data/relationships/{relationship.JsonKey}"); + + if (linkage.IsToMany) + throw new DeserializationException("Invalid linkage for to-one relationship", + "Expected an object or null for to-one linkage", + $"/data/relationships/{relationship.JsonKey}/data"); + + var identifier = linkage.Identifiers.FirstOrDefault(); + if (identifier == null) + { + relationship.Property.SetValue(ephemeralResource, null); + } + else + { + var relatedObjectRegistration = _resourceTypeRegistry.GetRegistrationForResourceTypeName(identifier.Type); + var relatedObject = _ephemeralRelatedResourceCreator.CreateEphemeralResource(relatedObjectRegistration, identifier.Id); + + relationship.Property.SetValue(ephemeralResource, relatedObject); + } + } + + protected virtual void SetPropertyForToManyRelationship(T ephemeralResource, ResourceTypeRelationship relationship, + IResourceLinkage linkage) + { + if (linkage == null) + throw new DeserializationException("Missing linkage for to-many relationship", + "Expected an array for to-many linkage, but no linkage was specified.", $"/data/relationships/{relationship.JsonKey}"); + + if (!linkage.IsToMany) + throw new DeserializationException("Invalid linkage for to-many relationship", + "Expected an array for to-many linkage.", + $"/data/relationships/{relationship.JsonKey}/data"); + + var newCollection = (from resourceIdentifier in linkage.Identifiers + let relatedObjectRegistration = _resourceTypeRegistry.GetRegistrationForResourceTypeName(resourceIdentifier.Type) + select _ephemeralRelatedResourceCreator.CreateEphemeralResource(relatedObjectRegistration, resourceIdentifier.Id)).ToList(); + + var method = _openSetToManyRelationshipValueMethod.MakeGenericMethod(relationship.RelatedType); + method.Invoke(this, new object[] { ephemeralResource, newCollection, relationship }); + } + + /// + /// Sets the value of a to-many relationship + /// + protected void SetToManyRelationshipValue(object material, IEnumerable relatedObjects, ResourceTypeRelationship relationship) + { + var currentValue = relationship.Property.GetValue(material); + var typedArray = relatedObjects.Select(o => (TRelated)o).ToArray(); + if (relationship.Property.PropertyType.IsAssignableFrom(typeof(List))) + { + if (currentValue == null) + { + relationship.Property.SetValue(material, typedArray.ToList()); + } + else + { + var listCurrentValue = (ICollection)currentValue; + var itemsToAdd = typedArray.Except(listCurrentValue); + var itemsToRemove = listCurrentValue.Except(typedArray).ToList(); + + foreach (var related in itemsToAdd) + listCurrentValue.Add(related); + + foreach (var related in itemsToRemove) + listCurrentValue.Remove(related); + } + } + else + { + relationship.Property.SetValue(material, typedArray); + } + } + } +} diff --git a/JSONAPI/Core/IEphemeralRelatedResourceCreator.cs b/JSONAPI/Core/IEphemeralRelatedResourceCreator.cs new file mode 100644 index 00000000..5fdc47c5 --- /dev/null +++ b/JSONAPI/Core/IEphemeralRelatedResourceCreator.cs @@ -0,0 +1,16 @@ +namespace JSONAPI.Core +{ + /// + /// Service for creating an instance of a registered resource type + /// + public interface IEphemeralRelatedResourceCreator + { + /// + /// Creates an instance of the specified resource type, with the given ID + /// + /// The type to create the instance for + /// The ID for the resource + /// A new instance of the specified resource type + object CreateEphemeralResource(IResourceTypeRegistration resourceTypeRegistration, string id); + } +} \ No newline at end of file diff --git a/JSONAPI/Core/IEphemeralRelatedResourceReader.cs b/JSONAPI/Core/IEphemeralRelatedResourceReader.cs new file mode 100644 index 00000000..95a8fade --- /dev/null +++ b/JSONAPI/Core/IEphemeralRelatedResourceReader.cs @@ -0,0 +1,35 @@ +using System; +using JSONAPI.Documents; + +namespace JSONAPI.Core +{ + /// + /// Populates property values on an ephemeral resource from a relationship object + /// + /// + [Obsolete] + public interface IEphemeralRelatedResourceReader + { + /// + /// Sets the property on the ephemeral resource that corresponds to the given property + /// + /// + /// + /// + void SetProperty(T ephemeralResource, string jsonKey, IRelationshipObject relationshipObject); + } + + /// + /// Populates property values on an ephemeral resource from a relationship object + /// + public interface IEphemeralRelatedResourceReader + { + /// + /// Sets the property on the ephemeral resource that corresponds to the given property + /// + /// + /// + /// + void SetProperty(T ephemeralResource, string jsonKey, IRelationshipObject relationshipObject); + } +} \ No newline at end of file diff --git a/JSONAPI/Core/ObjectComplexAttributeValueConverter.cs b/JSONAPI/Core/ObjectComplexAttributeValueConverter.cs new file mode 100644 index 00000000..c6c73af0 --- /dev/null +++ b/JSONAPI/Core/ObjectComplexAttributeValueConverter.cs @@ -0,0 +1,39 @@ +using System.Reflection; +using Newtonsoft.Json.Linq; + +namespace JSONAPI.Core +{ + /// + /// Implementation of suitable for + /// use with complex attributes that deserialize to custom types. + /// + public class ObjectComplexAttributeValueConverter : IAttributeValueConverter + { + private readonly PropertyInfo _property; + private readonly bool _isToMany; + + /// + /// Creates a new ComplexAttributeValueConverter + /// + /// + /// + public ObjectComplexAttributeValueConverter(PropertyInfo property, bool isToMany) + { + _property = property; + _isToMany = isToMany; + } + + public JToken GetValue(object resource) + { + var value = _property.GetValue(resource); + if (value == null) return null; + return _isToMany ? (JToken)JArray.FromObject(value) : JObject.FromObject(value); + } + + public void SetValue(object resource, JToken value) + { + var deserialized = value?.ToObject(_property.PropertyType); + _property.SetValue(resource, deserialized); + } + } +} \ No newline at end of file diff --git a/JSONAPI/Core/ResourceTypeRegistrar.cs b/JSONAPI/Core/ResourceTypeRegistrar.cs index 6ae7b057..bedd6e21 100644 --- a/JSONAPI/Core/ResourceTypeRegistrar.cs +++ b/JSONAPI/Core/ResourceTypeRegistrar.cs @@ -4,7 +4,6 @@ using System.Linq.Expressions; using System.Reflection; using JSONAPI.Attributes; -using JSONAPI.Configuration; using JSONAPI.Extensions; using Newtonsoft.Json; @@ -78,7 +77,14 @@ public IResourceTypeRegistration BuildRegistration(Type type, string resourceTyp filterByIdFactory = (param, id) => { var propertyExpr = Expression.Property(param, idProperty); - var idExpr = Expression.Constant(id); + object obj = id; + if (obj == null) + { + var t = propertyExpr.Type; + if (t.IsValueType) + obj = Activator.CreateInstance(t); + } + var idExpr = Expression.Constant(Convert.ChangeType(obj, propertyExpr.Type)); return Expression.Equal(propertyExpr, idExpr); }; } @@ -101,7 +107,15 @@ protected virtual IAttributeValueConverter GetValueConverterForProperty(Property { var serializeAsComplexAttribute = prop.GetCustomAttribute(); if (serializeAsComplexAttribute != null) - return new ComplexAttributeValueConverter(prop); + { + if (prop.PropertyType == typeof (string)) + return new ComplexAttributeValueConverter(prop); + + var isToMany = + prop.PropertyType.IsArray || + (prop.PropertyType.GetInterfaces().Contains(typeof(System.Collections.IEnumerable)) && prop.PropertyType.IsGenericType); + return new ObjectComplexAttributeValueConverter(prop, isToMany); + } if (prop.PropertyType == typeof(DateTime)) return new DateTimeAttributeValueConverter(prop, false); @@ -150,24 +164,30 @@ protected virtual ResourceTypeField CreateResourceTypeField(PropertyInfo prop) var type = prop.PropertyType; - if (prop.PropertyType.CanWriteAsJsonApiAttribute()) + if (prop.PropertyType.CanWriteAsJsonApiAttribute() || prop.GetCustomAttributes().Any()) { var converter = GetValueConverterForProperty(prop); return new ResourceTypeAttribute(converter, prop, jsonKey); } var selfLinkTemplateAttribute = prop.GetCustomAttributes().OfType().FirstOrDefault(); - var selfLinkTemplate = selfLinkTemplateAttribute == null ? null : selfLinkTemplateAttribute.TemplateString; + var selfLinkTemplate = selfLinkTemplateAttribute?.TemplateString; var relatedResourceLinkTemplateAttribute = prop.GetCustomAttributes().OfType().FirstOrDefault(); - var relatedResourceLinkTemplate = relatedResourceLinkTemplateAttribute == null ? null : relatedResourceLinkTemplateAttribute.TemplateString; + var relatedResourceLinkTemplate = relatedResourceLinkTemplateAttribute?.TemplateString; var isToMany = type.IsArray || (type.GetInterfaces().Contains(typeof(System.Collections.IEnumerable)) && type.IsGenericType); - if (!isToMany) return new ToOneResourceTypeRelationship(prop, jsonKey, type, selfLinkTemplate, relatedResourceLinkTemplate); + var linkSettingsAttribute = prop.GetCustomAttributes().OfType().FirstOrDefault(); + var serializeRelationshipLink = linkSettingsAttribute == null || linkSettingsAttribute.SerializeRelationshipLink; + var serializeRelatedResourceLink = linkSettingsAttribute == null || linkSettingsAttribute.SerializeRelatedResourceLink; + + if (!isToMany) return new ToOneResourceTypeRelationship(prop, jsonKey, type, selfLinkTemplate, relatedResourceLinkTemplate, + serializeRelationshipLink, serializeRelatedResourceLink); var relatedType = type.IsGenericType ? type.GetGenericArguments()[0] : type.GetElementType(); - return new ToManyResourceTypeRelationship(prop, jsonKey, relatedType, selfLinkTemplate, relatedResourceLinkTemplate); + return new ToManyResourceTypeRelationship(prop, jsonKey, relatedType, selfLinkTemplate, relatedResourceLinkTemplate, + serializeRelationshipLink, serializeRelatedResourceLink); } /// @@ -181,7 +201,7 @@ protected virtual PropertyInfo CalculateIdProperty(Type type) type .GetProperties() .FirstOrDefault(p => p.CustomAttributes.Any(attr => attr.AttributeType == typeof(UseAsIdAttribute))) - ?? type.GetProperty("Id"); + ?? type.GetProperty("Id") ?? type.GetProperty("ID"); } } } \ No newline at end of file diff --git a/JSONAPI/Core/ResourceTypeRegistration.cs b/JSONAPI/Core/ResourceTypeRegistration.cs index 5eef1b6c..ad35ef2f 100644 --- a/JSONAPI/Core/ResourceTypeRegistration.cs +++ b/JSONAPI/Core/ResourceTypeRegistration.cs @@ -4,7 +4,6 @@ using System.Linq; using System.Linq.Expressions; using System.Reflection; -using JSONAPI.Http; namespace JSONAPI.Core { @@ -22,6 +21,9 @@ internal ResourceTypeRegistration(Type type, PropertyInfo idProperty, string res Func filterByIdExpressionFactory, Func sortByIdExpressionFactory) { + if (type == null) throw new ArgumentNullException(nameof(type)); + if (idProperty == null) throw new ArgumentNullException(nameof(idProperty)); + if (resourceTypeName == null) throw new ArgumentNullException(nameof(resourceTypeName)); IdProperty = idProperty; Type = type; ResourceTypeName = resourceTypeName; @@ -44,11 +46,15 @@ internal ResourceTypeRegistration(Type type, PropertyInfo idProperty, string res public string GetIdForResource(object resource) { - return IdProperty.GetValue(resource).ToString(); + if (resource == null) throw new ArgumentNullException(nameof(resource)); + var resourceId = IdProperty.GetValue(resource); + if (resourceId == null) throw new ArgumentException($"The ID for the provided `{ResourceTypeName}` resource is null."); + return resourceId.ToString(); } public void SetIdForResource(object resource, string id) { + if (resource == null) throw new ArgumentNullException(nameof(resource)); IdProperty.SetValue(resource, id); // TODO: handle classes with non-string ID types } diff --git a/JSONAPI/Core/ResourceTypeRegistry.cs b/JSONAPI/Core/ResourceTypeRegistry.cs index 7f790e98..57c3cbd0 100644 --- a/JSONAPI/Core/ResourceTypeRegistry.cs +++ b/JSONAPI/Core/ResourceTypeRegistry.cs @@ -57,10 +57,13 @@ public void AddRegistration(IResourceTypeRegistration registration) throw new InvalidOperationException(String.Format("The type `{0}` has already been registered.", registration.Type.FullName)); - if (_registrationsByName.ContainsKey(registration.ResourceTypeName)) + IResourceTypeRegistration existingRegistration; + if (_registrationsByName.TryGetValue(registration.ResourceTypeName, out existingRegistration)) throw new InvalidOperationException( - String.Format("The resource type name `{0}` has already been registered.", - registration.ResourceTypeName)); + String.Format("Could not register `{0} under resource type name `{1}` because `{1}` has already been registered by `{2}`.", + registration.Type.FullName, + registration.ResourceTypeName, + existingRegistration.Type.FullName)); _registrationsByType.Add(registration.Type, registration); _registrationsByName.Add(registration.ResourceTypeName, registration); diff --git a/JSONAPI/Core/ResourceTypeRelationship.cs b/JSONAPI/Core/ResourceTypeRelationship.cs index d5ba5576..2955d94d 100644 --- a/JSONAPI/Core/ResourceTypeRelationship.cs +++ b/JSONAPI/Core/ResourceTypeRelationship.cs @@ -9,13 +9,16 @@ namespace JSONAPI.Core public abstract class ResourceTypeRelationship : ResourceTypeField { internal ResourceTypeRelationship(PropertyInfo property, string jsonKey, Type relatedType, - string selfLinkTemplate, string relatedResourceLinkTemplate, bool isToMany) + string selfLinkTemplate, string relatedResourceLinkTemplate, bool isToMany, + bool serializeRelationshipLink = true, bool serializeRelatedResourceLink = true) : base(property, jsonKey) { RelatedType = relatedType; SelfLinkTemplate = selfLinkTemplate; RelatedResourceLinkTemplate = relatedResourceLinkTemplate; IsToMany = isToMany; + SerializeRelationshipLink = serializeRelationshipLink; + SerializeRelatedResourceLink = serializeRelatedResourceLink; } /// @@ -41,5 +44,17 @@ internal ResourceTypeRelationship(PropertyInfo property, string jsonKey, Type re /// relationship belongs to. /// public string RelatedResourceLinkTemplate { get; private set; } + + /// + /// Whether to include a link to the relationship URL when serializing relationship objects for + /// this relationship + /// + public bool SerializeRelationshipLink { get; private set; } + + /// + /// Whether to include a link to the related resource URL when serializing relationship objects for + /// this relationship + /// + public bool SerializeRelatedResourceLink { get; private set; } } } \ No newline at end of file diff --git a/JSONAPI/Core/ToManyResourceTypeRelationship.cs b/JSONAPI/Core/ToManyResourceTypeRelationship.cs index c61b9e5b..0337ed85 100644 --- a/JSONAPI/Core/ToManyResourceTypeRelationship.cs +++ b/JSONAPI/Core/ToManyResourceTypeRelationship.cs @@ -9,8 +9,8 @@ namespace JSONAPI.Core public sealed class ToManyResourceTypeRelationship : ResourceTypeRelationship { internal ToManyResourceTypeRelationship(PropertyInfo property, string jsonKey, Type relatedType, - string selfLinkTemplate, string relatedResourceLinkTemplate) - : base(property, jsonKey, relatedType, selfLinkTemplate, relatedResourceLinkTemplate, true) + string selfLinkTemplate, string relatedResourceLinkTemplate, bool serializeRelationshipLink = true, bool serializeRelatedResourceLink = true) + : base(property, jsonKey, relatedType, selfLinkTemplate, relatedResourceLinkTemplate, true, serializeRelationshipLink, serializeRelatedResourceLink) { } } diff --git a/JSONAPI/Core/ToOneResourceTypeRelationship.cs b/JSONAPI/Core/ToOneResourceTypeRelationship.cs index cb9a57c4..25d148c8 100644 --- a/JSONAPI/Core/ToOneResourceTypeRelationship.cs +++ b/JSONAPI/Core/ToOneResourceTypeRelationship.cs @@ -9,8 +9,8 @@ namespace JSONAPI.Core public sealed class ToOneResourceTypeRelationship : ResourceTypeRelationship { internal ToOneResourceTypeRelationship(PropertyInfo property, string jsonKey, Type relatedType, - string selfLinkTemplate, string relatedResourceLinkTemplate) - : base(property, jsonKey, relatedType, selfLinkTemplate, relatedResourceLinkTemplate, false) + string selfLinkTemplate, string relatedResourceLinkTemplate, bool serializeRelationshipLink = true, bool serializeRelatedResourceLink = true) + : base(property, jsonKey, relatedType, selfLinkTemplate, relatedResourceLinkTemplate, false, serializeRelationshipLink, serializeRelatedResourceLink) { } } diff --git a/JSONAPI/Documents/Builders/DefaultQueryableResourceCollectionDocumentBuilder.cs b/JSONAPI/Documents/Builders/DefaultQueryableResourceCollectionDocumentBuilder.cs index 79061d06..d0488b1c 100644 --- a/JSONAPI/Documents/Builders/DefaultQueryableResourceCollectionDocumentBuilder.cs +++ b/JSONAPI/Documents/Builders/DefaultQueryableResourceCollectionDocumentBuilder.cs @@ -38,11 +38,11 @@ public DefaultQueryableResourceCollectionDocumentBuilder( _baseUrlService = baseUrlService; } - public async Task BuildDocument(IQueryable query, HttpRequestMessage request, CancellationToken cancellationToken, + public async Task BuildDocument(IQueryable query, HttpRequestMessage request, string[] sortExpressions, CancellationToken cancellationToken, string[] includes = null) { var filteredQuery = _filteringTransformer.Filter(query, request); - var sortedQuery = _sortingTransformer.Sort(filteredQuery, request); + var sortedQuery = _sortingTransformer.Sort(filteredQuery, sortExpressions); var paginationResults = _paginationTransformer.ApplyPagination(sortedQuery, request); var paginatedQuery = paginationResults.PagedQuery; @@ -51,7 +51,7 @@ public async Task BuildDocument(IQueryable qu var results = await _enumerationTransformer.Enumerate(paginatedQuery, cancellationToken); var metadata = await GetDocumentMetadata(query, filteredQuery, sortedQuery, paginationResults, cancellationToken); - return _resourceCollectionDocumentBuilder.BuildDocument(results, linkBaseUrl, includes, metadata); + return _resourceCollectionDocumentBuilder.BuildDocument(results, linkBaseUrl, includes, metadata, null); } /// @@ -60,7 +60,7 @@ public async Task BuildDocument(IQueryable qu protected virtual Task GetDocumentMetadata(IQueryable originalQuery, IQueryable filteredQuery, IOrderedQueryable sortedQuery, IPaginationTransformResult paginationResult, CancellationToken cancellationToken) { - return Task.FromResult((IMetadata)null); + return Task.FromResult((IMetadata) null); } } } diff --git a/JSONAPI/Documents/Builders/FallbackDocumentBuilder.cs b/JSONAPI/Documents/Builders/FallbackDocumentBuilder.cs index b93beb9c..8208f274 100644 --- a/JSONAPI/Documents/Builders/FallbackDocumentBuilder.cs +++ b/JSONAPI/Documents/Builders/FallbackDocumentBuilder.cs @@ -17,6 +17,8 @@ public class FallbackDocumentBuilder : IFallbackDocumentBuilder private readonly ISingleResourceDocumentBuilder _singleResourceDocumentBuilder; private readonly IQueryableResourceCollectionDocumentBuilder _queryableResourceCollectionDocumentBuilder; private readonly IResourceCollectionDocumentBuilder _resourceCollectionDocumentBuilder; + private readonly ISortExpressionExtractor _sortExpressionExtractor; + private readonly IIncludeExpressionExtractor _includeExpressionExtractor; private readonly IBaseUrlService _baseUrlService; private readonly Lazy _openBuildDocumentFromQueryableMethod; private readonly Lazy _openBuildDocumentFromEnumerableMethod; @@ -27,11 +29,15 @@ public class FallbackDocumentBuilder : IFallbackDocumentBuilder public FallbackDocumentBuilder(ISingleResourceDocumentBuilder singleResourceDocumentBuilder, IQueryableResourceCollectionDocumentBuilder queryableResourceCollectionDocumentBuilder, IResourceCollectionDocumentBuilder resourceCollectionDocumentBuilder, + ISortExpressionExtractor sortExpressionExtractor, + IIncludeExpressionExtractor includeExpressionExtractor, IBaseUrlService baseUrlService) { _singleResourceDocumentBuilder = singleResourceDocumentBuilder; _queryableResourceCollectionDocumentBuilder = queryableResourceCollectionDocumentBuilder; _resourceCollectionDocumentBuilder = resourceCollectionDocumentBuilder; + _sortExpressionExtractor = sortExpressionExtractor; + _includeExpressionExtractor = includeExpressionExtractor; _baseUrlService = baseUrlService; _openBuildDocumentFromQueryableMethod = @@ -50,6 +56,9 @@ public async Task BuildDocument(object obj, HttpRequestMessage { var type = obj.GetType(); + // TODO: test includes + var includeExpressions = _includeExpressionExtractor.ExtractIncludeExpressions(requestMessage); + var queryableInterfaces = type.GetInterfaces(); var queryableInterface = queryableInterfaces.FirstOrDefault( @@ -60,8 +69,10 @@ public async Task BuildDocument(object obj, HttpRequestMessage var buildDocumentMethod = _openBuildDocumentFromQueryableMethod.Value.MakeGenericMethod(queryableElementType); + var sortExpressions = _sortExpressionExtractor.ExtractSortExpressions(requestMessage); + dynamic materializedQueryTask = buildDocumentMethod.Invoke(_queryableResourceCollectionDocumentBuilder, - new[] { obj, requestMessage, cancellationToken, null }); + new[] { obj, requestMessage, sortExpressions, cancellationToken, includeExpressions }); return await materializedQueryTask; } @@ -80,11 +91,11 @@ public async Task BuildDocument(object obj, HttpRequestMessage var buildDocumentMethod = _openBuildDocumentFromEnumerableMethod.Value.MakeGenericMethod(enumerableElementType); return - (dynamic)buildDocumentMethod.Invoke(_resourceCollectionDocumentBuilder, new[] { obj, linkBaseUrl, new string[] { }, null }); + (dynamic)buildDocumentMethod.Invoke(_resourceCollectionDocumentBuilder, new[] { obj, linkBaseUrl, new string[] { }, null, null }); } // Single resource object - return _singleResourceDocumentBuilder.BuildDocument(obj, linkBaseUrl, null, null); + return _singleResourceDocumentBuilder.BuildDocument(obj, linkBaseUrl, includeExpressions, null); } private static Type GetEnumerableElementType(Type collectionType) diff --git a/JSONAPI/Documents/Builders/IQueryableResourceCollectionDocumentBuilder.cs b/JSONAPI/Documents/Builders/IQueryableResourceCollectionDocumentBuilder.cs index c1040d49..0c15cb50 100644 --- a/JSONAPI/Documents/Builders/IQueryableResourceCollectionDocumentBuilder.cs +++ b/JSONAPI/Documents/Builders/IQueryableResourceCollectionDocumentBuilder.cs @@ -1,5 +1,4 @@ -using System; -using System.Linq; +using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -16,11 +15,12 @@ public interface IQueryableResourceCollectionDocumentBuilder /// /// The query to materialize to build the response document /// The request containing parameters to determine how to sort/filter/paginate the query + /// An array of paths to sort by /// /// The set of paths to include in the compound document /// /// - Task BuildDocument(IQueryable query, HttpRequestMessage request, CancellationToken cancellationToken, + Task BuildDocument(IQueryable query, HttpRequestMessage request, string[] sortExpressions, CancellationToken cancellationToken, string[] includePaths = null); } } diff --git a/JSONAPI/Documents/Builders/IResourceCollectionDocumentBuilder.cs b/JSONAPI/Documents/Builders/IResourceCollectionDocumentBuilder.cs index 899eb99d..78d06d14 100644 --- a/JSONAPI/Documents/Builders/IResourceCollectionDocumentBuilder.cs +++ b/JSONAPI/Documents/Builders/IResourceCollectionDocumentBuilder.cs @@ -15,8 +15,10 @@ public interface IResourceCollectionDocumentBuilder /// A list of dot-separated paths to include in the compound document. /// If this collection is null or empty, no linkage will be included. /// Metadata for the top-level + /// Metadata to associate with individual resource objects /// /// - IResourceCollectionDocument BuildDocument(IEnumerable primaryData, string linkBaseUrl, string[] includePathExpressions, IMetadata metadata); + IResourceCollectionDocument BuildDocument(IEnumerable primaryData, string linkBaseUrl, string[] includePathExpressions, IMetadata metadata, + IDictionary resourceMetadata = null); } } \ No newline at end of file diff --git a/JSONAPI/Documents/Builders/ISingleResourceDocumentBuilder.cs b/JSONAPI/Documents/Builders/ISingleResourceDocumentBuilder.cs index cd35d6d7..6fc032fa 100644 --- a/JSONAPI/Documents/Builders/ISingleResourceDocumentBuilder.cs +++ b/JSONAPI/Documents/Builders/ISingleResourceDocumentBuilder.cs @@ -1,4 +1,6 @@ -namespace JSONAPI.Documents.Builders +using System.Collections.Generic; + +namespace JSONAPI.Documents.Builders { /// /// Builds a response document from primary data objects @@ -13,7 +15,9 @@ public interface ISingleResourceDocumentBuilder /// A list of dot-separated paths to include in the compound document. /// If this collection is null or empty, no linkage will be included. /// Metadata to serialize at the top level of the document + /// Metadata about individual resource objects /// - ISingleResourceDocument BuildDocument(object primaryData, string linkBaseUrl, string[] includePathExpressions, IMetadata topLevelMetadata); + ISingleResourceDocument BuildDocument(object primaryData, string linkBaseUrl, string[] includePathExpressions, IMetadata topLevelMetadata, + IDictionary resourceMetadata = null); } } diff --git a/JSONAPI/Documents/Builders/JsonApiException.cs b/JSONAPI/Documents/Builders/JsonApiException.cs index a8b067a3..ee6d04f9 100644 --- a/JSONAPI/Documents/Builders/JsonApiException.cs +++ b/JSONAPI/Documents/Builders/JsonApiException.cs @@ -19,7 +19,7 @@ public class JsonApiException : Exception /// Creates a new JsonApiException /// /// - public JsonApiException(IError error) + public JsonApiException(IError error) : base(error?.Detail ?? "An error occurred in JSONAPI") { Error = error; } @@ -55,6 +55,21 @@ public static JsonApiException Create(string title, string detail, HttpStatusCod return new JsonApiException(error); } + /// + /// Creates a JsonApiException to send a 400 Bad Request error. + /// + public static JsonApiException CreateForBadRequest(string detail = null) + { + var error = new Error + { + Id = Guid.NewGuid().ToString(), + Status = HttpStatusCode.BadRequest, + Title = "Bad request", + Detail = detail + }; + return new JsonApiException(error); + } + /// /// Creates a JsonApiException to send a 404 Not Found error. /// @@ -84,5 +99,20 @@ public static JsonApiException CreateForForbidden(string detail = null) }; return new JsonApiException(error); } + + /// + /// Creates a JsonApiException to send a 405 Method Not Allowed error. + /// + public static JsonApiException CreateForMethodNotAllowed(string detail = null) + { + var error = new Error + { + Id = Guid.NewGuid().ToString(), + Status = HttpStatusCode.MethodNotAllowed, + Title = "Method not allowed", + Detail = detail + }; + return new JsonApiException(error); + } } } diff --git a/JSONAPI/Documents/Builders/RegistryDrivenDocumentBuilder.cs b/JSONAPI/Documents/Builders/RegistryDrivenDocumentBuilder.cs index 0689c759..69b6bbac 100644 --- a/JSONAPI/Documents/Builders/RegistryDrivenDocumentBuilder.cs +++ b/JSONAPI/Documents/Builders/RegistryDrivenDocumentBuilder.cs @@ -46,9 +46,10 @@ internal static bool PathExpressionMatchesCurrentPath(string currentPath, string /// /// /// + /// /// protected ResourceObject CreateResourceObject(object modelObject, IDictionary> idDictionariesByType, - string currentPath, string[] includePathExpressions, string linkBaseUrl) + string currentPath, string[] includePathExpressions, string linkBaseUrl, IDictionary resourceObjectMetadata) { if (modelObject == null) return null; @@ -97,7 +98,7 @@ protected ResourceObject CreateResourceObject(object modelObject, IDictionary diff --git a/JSONAPI/Documents/Builders/RegistryDrivenResourceCollectionDocumentBuilder.cs b/JSONAPI/Documents/Builders/RegistryDrivenResourceCollectionDocumentBuilder.cs index 2eb68c35..0508dd4a 100644 --- a/JSONAPI/Documents/Builders/RegistryDrivenResourceCollectionDocumentBuilder.cs +++ b/JSONAPI/Documents/Builders/RegistryDrivenResourceCollectionDocumentBuilder.cs @@ -19,15 +19,22 @@ public RegistryDrivenResourceCollectionDocumentBuilder(IResourceTypeRegistry res { } - public IResourceCollectionDocument BuildDocument(IEnumerable primaryData, string linkBaseUrl, string[] includePathExpressions, IMetadata metadata) + public IResourceCollectionDocument BuildDocument(IEnumerable primaryData, string linkBaseUrl, string[] includePathExpressions, IMetadata metadata, + IDictionary resourceMetadata = null) { var idDictionariesByType = new Dictionary>(); var primaryDataResources = - primaryData.Select(d => (IResourceObject)CreateResourceObject(d, idDictionariesByType, null, includePathExpressions, linkBaseUrl)) + primaryData.Select(d => (IResourceObject)CreateResourceObject(d, idDictionariesByType, null, includePathExpressions, linkBaseUrl, resourceMetadata)) .ToArray(); + var primaryResourceIdentifiers = primaryDataResources.Select(r => new { r.Id, r.Type }).ToArray(); + var relatedData = idDictionariesByType.Values.SelectMany(d => d.Values).Cast().ToArray(); - var document = new ResourceCollectionDocument(primaryDataResources, relatedData, metadata); + var relatedDataNotInPrimaryData = relatedData + .Where(r => !primaryResourceIdentifiers.Any(pri => pri.Id == r.Id && pri.Type == r.Type)) + .ToArray(); + + var document = new ResourceCollectionDocument(primaryDataResources, relatedDataNotInPrimaryData, metadata); return document; } } diff --git a/JSONAPI/Documents/Builders/RegistryDrivenSingleResourceDocumentBuilder.cs b/JSONAPI/Documents/Builders/RegistryDrivenSingleResourceDocumentBuilder.cs index 6930a5d3..029897b3 100644 --- a/JSONAPI/Documents/Builders/RegistryDrivenSingleResourceDocumentBuilder.cs +++ b/JSONAPI/Documents/Builders/RegistryDrivenSingleResourceDocumentBuilder.cs @@ -19,10 +19,11 @@ public RegistryDrivenSingleResourceDocumentBuilder(IResourceTypeRegistry resourc { } - public ISingleResourceDocument BuildDocument(object primaryData, string linkBaseUrl, string[] includePathExpressions, IMetadata topLevelMetadata) + public ISingleResourceDocument BuildDocument(object primaryData, string linkBaseUrl, string[] includePathExpressions, IMetadata topLevelMetadata, + IDictionary resourceMetadata = null) { var idDictionariesByType = new Dictionary>(); - var primaryDataResource = CreateResourceObject(primaryData, idDictionariesByType, null, includePathExpressions, linkBaseUrl); + var primaryDataResource = CreateResourceObject(primaryData, idDictionariesByType, null, includePathExpressions, linkBaseUrl, resourceMetadata); var relatedData = idDictionariesByType.Values.SelectMany(d => d.Values).Cast().ToArray(); var document = new SingleResourceDocument(primaryDataResource, relatedData, topLevelMetadata); diff --git a/JSONAPI/Documents/DefaultLinkConventions.cs b/JSONAPI/Documents/DefaultLinkConventions.cs index 6ca5f30e..88d1c500 100644 --- a/JSONAPI/Documents/DefaultLinkConventions.cs +++ b/JSONAPI/Documents/DefaultLinkConventions.cs @@ -10,6 +10,8 @@ public class DefaultLinkConventions : ILinkConventions { public ILink GetRelationshipLink(TResource relationshipOwner, IResourceTypeRegistry resourceTypeRegistry, ResourceTypeRelationship property, string baseUrl) { + if (!property.SerializeRelationshipLink) return null; + var url = BuildRelationshipUrl(relationshipOwner, resourceTypeRegistry, property, baseUrl); var metadata = GetMetadataForRelationshipLink(relationshipOwner, property); return new Link(url, metadata); @@ -17,6 +19,8 @@ public ILink GetRelationshipLink(TResource relationshipOwner, IResour public ILink GetRelatedResourceLink(TResource relationshipOwner, IResourceTypeRegistry resourceTypeRegistry, ResourceTypeRelationship property, string baseUrl) { + if (!property.SerializeRelatedResourceLink) return null; + var url = BuildRelatedResourceUrl(relationshipOwner, resourceTypeRegistry, property, baseUrl); var metadata = GetMetadataForRelatedResourceLink(relationshipOwner, property); return new Link(url, metadata); diff --git a/JSONAPI/Documents/IResourceLinkage.cs b/JSONAPI/Documents/IResourceLinkage.cs index ef3ca445..b45aef06 100644 --- a/JSONAPI/Documents/IResourceLinkage.cs +++ b/JSONAPI/Documents/IResourceLinkage.cs @@ -11,8 +11,8 @@ public interface IResourceLinkage bool IsToMany { get; } /// - /// The identifiers this linkage refers to. If IsToMany is true, this - /// property must return an array of length either 0 or 1. If false, + /// The identifiers this linkage refers to. If IsToMany is false, this + /// property must return an array of length either 0 or 1. If true, /// the array may be of any length. This property must not return null. /// IResourceIdentifier[] Identifiers { get; } diff --git a/JSONAPI/Documents/Metadata.cs b/JSONAPI/Documents/Metadata.cs new file mode 100644 index 00000000..004de8c7 --- /dev/null +++ b/JSONAPI/Documents/Metadata.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json.Linq; + +namespace JSONAPI.Documents +{ + /// + /// Default implementation of + /// + public class Metadata : IMetadata + { + public Metadata() + { + MetaObject = new JObject(); + } + public JObject MetaObject { get; } + } +} diff --git a/JSONAPI/Extensions/StringExtensions.cs b/JSONAPI/Extensions/StringExtensions.cs index 89826301..7f4e9ad4 100644 --- a/JSONAPI/Extensions/StringExtensions.cs +++ b/JSONAPI/Extensions/StringExtensions.cs @@ -2,7 +2,7 @@ namespace JSONAPI.Extensions { - internal static class StringExtensions + public static class StringExtensions { private static readonly Regex PascalizeRegex = new Regex(@"(?:^|_|\-|\.)(.)"); diff --git a/JSONAPI/Extensions/TypeExtensions.cs b/JSONAPI/Extensions/TypeExtensions.cs index 8df43e56..c7d849ca 100644 --- a/JSONAPI/Extensions/TypeExtensions.cs +++ b/JSONAPI/Extensions/TypeExtensions.cs @@ -1,6 +1,6 @@ using System; using System.Collections.Generic; -using Newtonsoft.Json; +using Newtonsoft.Json.Linq; namespace JSONAPI.Extensions { @@ -17,6 +17,7 @@ public static bool CanWriteAsJsonApiAttribute(this Type objectType) || typeof (DateTime).IsAssignableFrom(objectType) || typeof (DateTimeOffset).IsAssignableFrom(objectType) || typeof (String).IsAssignableFrom(objectType) + || typeof (JToken).IsAssignableFrom(objectType) || objectType.IsEnum; } diff --git a/JSONAPI/Http/BaseUrlService.cs b/JSONAPI/Http/BaseUrlService.cs index 174e83e5..df1ee1a1 100644 --- a/JSONAPI/Http/BaseUrlService.cs +++ b/JSONAPI/Http/BaseUrlService.cs @@ -8,11 +8,108 @@ namespace JSONAPI.Http /// public class BaseUrlService : IBaseUrlService { + private string _contextPath = string.Empty; + private Uri _publicOrigin; + + /// + /// Default constructor + /// + public BaseUrlService() { } + + /// + /// Constructor which provides a context path for the routes of JSONAPI.NET + /// + /// context path for the routes + public BaseUrlService(string contextPath) + { + CleanContextPath(contextPath); + } + + /// + /// Constructor which provides a public origin host and a context path for the routes of JSONAPI.NET. + /// If only public origin is desired provide emtpy string to contextPath. + /// + /// public hostname + /// context path for the routes + public BaseUrlService(Uri publicOrigin, string contextPath) + { + CleanContextPath(contextPath); + this._publicOrigin = publicOrigin; + } + + /// + /// Retrieve the base path to provide in responses. + /// + /// + /// public virtual string GetBaseUrl(HttpRequestMessage requestMessage) { - return - new Uri(requestMessage.RequestUri.AbsoluteUri.Replace(requestMessage.RequestUri.PathAndQuery, - String.Empty)).ToString(); + string pathAndQuery; + string absolutUri = requestMessage.RequestUri.AbsoluteUri; + if (_publicOrigin != null) + { + var publicUriBuilder = new UriBuilder(absolutUri) + { + Host = _publicOrigin.Host, + Scheme = _publicOrigin.Scheme, + Port = _publicOrigin.Port + }; + absolutUri = publicUriBuilder.Uri.AbsoluteUri; + pathAndQuery = publicUriBuilder.Uri.PathAndQuery; + } + else + { + pathAndQuery = requestMessage.RequestUri.PathAndQuery; + } + pathAndQuery = RemoveFromBegin(pathAndQuery, GetContextPath()); + pathAndQuery= pathAndQuery.TrimStart('/'); + var baseUrl = RemoveFromEnd(absolutUri, pathAndQuery); + return baseUrl; + } + + /// + /// Provides the context path to serve JSONAPI.NET without leading and trailing slash. + /// + /// + public string GetContextPath() + { + return _contextPath; + } + + /// + /// Makes sure thre are no slashes at the beginnig or end. + /// + /// + private void CleanContextPath(string contextPath) + { + if (!string.IsNullOrEmpty(contextPath) && !contextPath.EndsWith("/")) + { + contextPath = contextPath.TrimEnd('/'); + } + if (!string.IsNullOrEmpty(contextPath) && contextPath.StartsWith("/")) + { + contextPath = contextPath.TrimStart('/'); + } + _contextPath = contextPath; + } + + + private string RemoveFromEnd(string input, string suffix) + { + if (input.EndsWith(suffix)) + { + return input.Substring(0, input.Length - suffix.Length); + } + return input; + } + private string RemoveFromBegin(string input, string prefix) + { + prefix = "/" + prefix; + if (input.StartsWith(prefix)) + { + return input.Substring(prefix.Length, input.Length - prefix.Length); + } + return input; } } } \ No newline at end of file diff --git a/JSONAPI/Http/DefaultIncludeExpressionExtractor.cs b/JSONAPI/Http/DefaultIncludeExpressionExtractor.cs new file mode 100644 index 00000000..cb040b19 --- /dev/null +++ b/JSONAPI/Http/DefaultIncludeExpressionExtractor.cs @@ -0,0 +1,21 @@ +using System.Linq; +using System.Net.Http; + +namespace JSONAPI.Http +{ + /// + /// Default implementation of + /// + public class DefaultIncludeExpressionExtractor: IIncludeExpressionExtractor + { + private const string IncludeQueryParamKey = "include"; + + public string[] ExtractIncludeExpressions(HttpRequestMessage requestMessage) + { + var queryParams = requestMessage.GetQueryNameValuePairs(); + var includeParam = queryParams.FirstOrDefault(kvp => kvp.Key == IncludeQueryParamKey); + if (includeParam.Key != IncludeQueryParamKey) return new string[] { }; + return includeParam.Value.Split(','); + } + } +} diff --git a/JSONAPI/Http/DefaultSortExpressionExtractor.cs b/JSONAPI/Http/DefaultSortExpressionExtractor.cs new file mode 100644 index 00000000..59cb449a --- /dev/null +++ b/JSONAPI/Http/DefaultSortExpressionExtractor.cs @@ -0,0 +1,21 @@ +using System.Linq; +using System.Net.Http; + +namespace JSONAPI.Http +{ + /// + /// Default implementation of + /// + public class DefaultSortExpressionExtractor : ISortExpressionExtractor + { + private const string SortQueryParamKey = "sort"; + + public string[] ExtractSortExpressions(HttpRequestMessage requestMessage) + { + var queryParams = requestMessage.GetQueryNameValuePairs(); + var sortParam = queryParams.FirstOrDefault(kvp => kvp.Key == SortQueryParamKey); + if (sortParam.Key != SortQueryParamKey) return new string[] {}; + return sortParam.Value.Split(','); + } + } +} \ No newline at end of file diff --git a/JSONAPI/Http/IBaseUrlService.cs b/JSONAPI/Http/IBaseUrlService.cs index ac5aa9f0..dd9f3177 100644 --- a/JSONAPI/Http/IBaseUrlService.cs +++ b/JSONAPI/Http/IBaseUrlService.cs @@ -11,5 +11,11 @@ public interface IBaseUrlService /// Gets the base URL for a request /// string GetBaseUrl(HttpRequestMessage requestMessage); + + /// + /// Gets the context path JSONAPI is served under without slashes at the beginning and end. + /// + /// + string GetContextPath(); } } diff --git a/JSONAPI/Http/IDocumentMaterializer.cs b/JSONAPI/Http/IDocumentMaterializer.cs index efe4d9bb..b4eb4bce 100644 --- a/JSONAPI/Http/IDocumentMaterializer.cs +++ b/JSONAPI/Http/IDocumentMaterializer.cs @@ -39,6 +39,6 @@ Task UpdateRecord(string id, ISingleResourceDocument re /// /// Deletes the record corresponding to the given id. /// - Task DeleteRecord(string id, CancellationToken cancellationToken); + Task DeleteRecord(string id, HttpRequestMessage request, CancellationToken cancellationToken); } } \ No newline at end of file diff --git a/JSONAPI/Http/IIncludeExpressionExtractor.cs b/JSONAPI/Http/IIncludeExpressionExtractor.cs new file mode 100644 index 00000000..c8cf1358 --- /dev/null +++ b/JSONAPI/Http/IIncludeExpressionExtractor.cs @@ -0,0 +1,17 @@ +using System.Net.Http; + +namespace JSONAPI.Http +{ + /// + /// Service to extract include expressions from an HTTP request + /// + public interface IIncludeExpressionExtractor + { + /// + /// Extracts include expressions from the request + /// + /// + /// + string[] ExtractIncludeExpressions(HttpRequestMessage requestMessage); + } +} diff --git a/JSONAPI/Http/ISortExpressionExtractor.cs b/JSONAPI/Http/ISortExpressionExtractor.cs new file mode 100644 index 00000000..62f9d42f --- /dev/null +++ b/JSONAPI/Http/ISortExpressionExtractor.cs @@ -0,0 +1,17 @@ +using System.Net.Http; + +namespace JSONAPI.Http +{ + /// + /// Service to extract sort expressions from an HTTP request + /// + public interface ISortExpressionExtractor + { + /// + /// Extracts sort expressions from the request + /// + /// + /// + string[] ExtractSortExpressions(HttpRequestMessage requestMessage); + } +} diff --git a/JSONAPI/Http/JsonApiController.cs b/JSONAPI/Http/JsonApiController.cs index 78f5b66a..8bc9bf22 100644 --- a/JSONAPI/Http/JsonApiController.cs +++ b/JSONAPI/Http/JsonApiController.cs @@ -63,7 +63,7 @@ public virtual async Task Post(string resourceType, [FromBody } /// - /// Updates the record with the given ID with data from the request payloaad. + /// Updates the record with the given ID with data from the request payload. /// public virtual async Task Patch(string resourceType, string id, [FromBody]ISingleResourceDocument requestDocument, CancellationToken cancellationToken) { @@ -78,7 +78,7 @@ public virtual async Task Patch(string resourceType, string i public virtual async Task Delete(string resourceType, string id, CancellationToken cancellationToken) { var materializer = _documentMaterializerLocator.GetMaterializerByResourceTypeName(resourceType); - var document = await materializer.DeleteRecord(id, cancellationToken); + var document = await materializer.DeleteRecord(id, Request, cancellationToken); return Ok(document); } } diff --git a/JSONAPI/Http/MappedDocumentMaterializer.cs b/JSONAPI/Http/MappedDocumentMaterializer.cs index b73934c1..4c53c2e0 100644 --- a/JSONAPI/Http/MappedDocumentMaterializer.cs +++ b/JSONAPI/Http/MappedDocumentMaterializer.cs @@ -1,9 +1,7 @@ using System; -using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; using System.Net.Http; -using System.Reflection; using System.Threading; using System.Threading.Tasks; using JSONAPI.Core; @@ -24,6 +22,7 @@ public abstract class MappedDocumentMaterializer : IDocumentMater private readonly IBaseUrlService _baseUrlService; private readonly ISingleResourceDocumentBuilder _singleResourceDocumentBuilder; private readonly IQueryableEnumerationTransformer _queryableEnumerationTransformer; + private readonly ISortExpressionExtractor _sortExpressionExtractor; private readonly IResourceTypeRegistry _resourceTypeRegistry; /// @@ -49,12 +48,14 @@ protected MappedDocumentMaterializer( IBaseUrlService baseUrlService, ISingleResourceDocumentBuilder singleResourceDocumentBuilder, IQueryableEnumerationTransformer queryableEnumerationTransformer, + ISortExpressionExtractor sortExpressionExtractor, IResourceTypeRegistry resourceTypeRegistry) { _queryableResourceCollectionDocumentBuilder = queryableResourceCollectionDocumentBuilder; _baseUrlService = baseUrlService; _singleResourceDocumentBuilder = singleResourceDocumentBuilder; _queryableEnumerationTransformer = queryableEnumerationTransformer; + _sortExpressionExtractor = sortExpressionExtractor; _resourceTypeRegistry = resourceTypeRegistry; } @@ -69,7 +70,11 @@ public virtual async Task GetRecords(HttpRequestMes var includePaths = GetIncludePathsForQuery() ?? new Expression>[] { }; var jsonApiPaths = includePaths.Select(ConvertToJsonKeyPath).ToArray(); var mappedQuery = GetMappedQuery(entityQuery, includePaths); - return await _queryableResourceCollectionDocumentBuilder.BuildDocument(mappedQuery, request, cancellationToken, jsonApiPaths); + var sortationPaths = _sortExpressionExtractor.ExtractSortExpressions(request); + if (sortationPaths == null || !sortationPaths.Any()) + sortationPaths = GetDefaultSortExpressions(); + + return await _queryableResourceCollectionDocumentBuilder.BuildDocument(mappedQuery, request, sortationPaths, cancellationToken, jsonApiPaths); } public virtual async Task GetRecordById(string id, HttpRequestMessage request, CancellationToken cancellationToken) @@ -83,6 +88,8 @@ public virtual async Task GetRecordById(string id, Http throw JsonApiException.CreateForNotFound( string.Format("No record exists with type `{0}` and ID `{1}`.", ResourceTypeName, id)); + await OnResourceFetched(primaryResource, cancellationToken); + var baseUrl = _baseUrlService.GetBaseUrl(request); return _singleResourceDocumentBuilder.BuildDocument(primaryResource, baseUrl, jsonApiPaths, null); } @@ -95,8 +102,8 @@ public abstract Task UpdateRecord(string id, ISingleRes HttpRequestMessage request, CancellationToken cancellationToken); - public abstract Task DeleteRecord(string id, CancellationToken cancellationToken); - + public abstract Task DeleteRecord(string id, HttpRequestMessage request, CancellationToken cancellationToken); + /// /// Returns a list of property paths to be included when constructing a query for this resource type /// @@ -113,6 +120,26 @@ protected virtual Expression>[] GetIncludePathsForSingleResou return null; } + /// + /// Hook for specifying sort expressions when fetching a collection + /// + /// + protected virtual string[] GetDefaultSortExpressions() + { + return new[] { "id" }; + } + + /// + /// Hook for performing any final modifications to the resource before serialization + /// + /// + /// + /// + protected virtual Task OnResourceFetched(TDto resource, CancellationToken cancellationToken) + { + return Task.FromResult(0); + } + private string ConvertToJsonKeyPath(Expression> expression) { var visitor = new PathVisitor(_resourceTypeRegistry); diff --git a/JSONAPI/Http/QueryableToManyRelatedResourceDocumentMaterializer.cs b/JSONAPI/Http/QueryableToManyRelatedResourceDocumentMaterializer.cs index 5bd863e6..6aa2286f 100644 --- a/JSONAPI/Http/QueryableToManyRelatedResourceDocumentMaterializer.cs +++ b/JSONAPI/Http/QueryableToManyRelatedResourceDocumentMaterializer.cs @@ -14,21 +14,37 @@ namespace JSONAPI.Http public abstract class QueryableToManyRelatedResourceDocumentMaterializer : IRelatedResourceDocumentMaterializer { private readonly IQueryableResourceCollectionDocumentBuilder _queryableResourceCollectionDocumentBuilder; + private readonly ISortExpressionExtractor _sortExpressionExtractor; + private readonly IIncludeExpressionExtractor _includeExpressionExtractor; + /// + /// List of includes given by url. + /// + protected string[] Includes = {}; /// /// Creates a new QueryableRelatedResourceDocumentMaterializer /// - protected QueryableToManyRelatedResourceDocumentMaterializer(IQueryableResourceCollectionDocumentBuilder queryableResourceCollectionDocumentBuilder) + protected QueryableToManyRelatedResourceDocumentMaterializer( + IQueryableResourceCollectionDocumentBuilder queryableResourceCollectionDocumentBuilder, + ISortExpressionExtractor sortExpressionExtractor, + IIncludeExpressionExtractor includeExpressionExtractor) { _queryableResourceCollectionDocumentBuilder = queryableResourceCollectionDocumentBuilder; + _sortExpressionExtractor = sortExpressionExtractor; + _includeExpressionExtractor = includeExpressionExtractor; } public async Task GetRelatedResourceDocument(string primaryResourceId, HttpRequestMessage request, CancellationToken cancellationToken) { + Includes = _includeExpressionExtractor.ExtractIncludeExpressions(request); var query = await GetRelatedQuery(primaryResourceId, cancellationToken); - var includes = GetIncludePaths(); - return await _queryableResourceCollectionDocumentBuilder.BuildDocument(query, request, cancellationToken, includes); // TODO: allow implementors to specify metadata + var sortExpressions = _sortExpressionExtractor.ExtractSortExpressions(request); + if (sortExpressions == null || sortExpressions.Length < 1) + sortExpressions = GetDefaultSortExpressions(); + + + return await _queryableResourceCollectionDocumentBuilder.BuildDocument(query, request, sortExpressions, cancellationToken, Includes); // TODO: allow implementors to specify metadata } /// @@ -37,10 +53,10 @@ public async Task GetRelatedResourceDocument(string primaryRes protected abstract Task> GetRelatedQuery(string primaryResourceId, CancellationToken cancellationToken); /// - /// Gets a list of relationship paths to include + /// If the client doesn't request any sort expressions, these expressions will be used for sorting instead. /// /// - protected virtual string[] GetIncludePaths() + protected virtual string[] GetDefaultSortExpressions() { return null; } diff --git a/JSONAPI/JSONAPI.csproj b/JSONAPI/JSONAPI.csproj index 7398c9c5..9bd3d2e2 100644 --- a/JSONAPI/JSONAPI.csproj +++ b/JSONAPI/JSONAPI.csproj @@ -67,6 +67,7 @@ + @@ -74,9 +75,14 @@ + + + + + @@ -84,9 +90,14 @@ + + + + + diff --git a/JSONAPI/Json/LinkFormatter.cs b/JSONAPI/Json/LinkFormatter.cs index 7c88dae3..8f0331b5 100644 --- a/JSONAPI/Json/LinkFormatter.cs +++ b/JSONAPI/Json/LinkFormatter.cs @@ -10,10 +10,18 @@ namespace JSONAPI.Json /// public class LinkFormatter : ILinkFormatter { - private readonly IMetadataFormatter _metadataFormatter; + private IMetadataFormatter _metadataFormatter; private const string HrefKeyName = "href"; private const string MetaKeyName = "meta"; + /// + /// Constructs a LinkFormatter + /// + public LinkFormatter() + { + + } + /// /// Constructs a LinkFormatter /// @@ -23,6 +31,23 @@ public LinkFormatter(IMetadataFormatter metadataFormatter) _metadataFormatter = metadataFormatter; } + /// + /// The formatter to use for metadata on the link object + /// + /// + public IMetadataFormatter MetadataFormatter + { + get + { + return _metadataFormatter ?? (_metadataFormatter = new MetadataFormatter()); + } + set + { + if (_metadataFormatter != null) throw new InvalidOperationException("This property can only be set once."); + _metadataFormatter = value; + } + } + public Task Serialize(ILink link, JsonWriter writer) { if (link.Metadata == null) @@ -35,7 +60,7 @@ public Task Serialize(ILink link, JsonWriter writer) writer.WritePropertyName(HrefKeyName); writer.WriteValue(link.Href); writer.WritePropertyName(MetaKeyName); - _metadataFormatter.Serialize(link.Metadata, writer); + MetadataFormatter.Serialize(link.Metadata, writer); writer.WriteEndObject(); } return Task.FromResult(0); diff --git a/JSONAPI/Json/RelationshipObjectFormatter.cs b/JSONAPI/Json/RelationshipObjectFormatter.cs index e598894c..a41c8411 100644 --- a/JSONAPI/Json/RelationshipObjectFormatter.cs +++ b/JSONAPI/Json/RelationshipObjectFormatter.cs @@ -16,9 +16,17 @@ public class RelationshipObjectFormatter : IRelationshipObjectFormatter private const string LinkageKeyName = "data"; private const string MetaKeyName = "meta"; - private readonly ILinkFormatter _linkFormatter; - private readonly IResourceLinkageFormatter _resourceLinkageFormatter; - private readonly IMetadataFormatter _metadataFormatter; + private ILinkFormatter _linkFormatter; + private IResourceLinkageFormatter _resourceLinkageFormatter; + private IMetadataFormatter _metadataFormatter; + + /// + /// Creates a new RelationshipObjectFormatter + /// + public RelationshipObjectFormatter () + { + + } /// /// Creates a new RelationshipObjectFormatter @@ -30,6 +38,45 @@ public RelationshipObjectFormatter(ILinkFormatter linkFormatter, IResourceLinkag _metadataFormatter = metadataFormatter; } + private ILinkFormatter LinkFormatter + { + get + { + return _linkFormatter ?? (_linkFormatter = new LinkFormatter()); + } + set + { + if (_linkFormatter != null) throw new InvalidOperationException("This property can only be set once."); + _linkFormatter = value; + } + } + + private IResourceLinkageFormatter ResourceLinkageFormatter + { + get + { + return _resourceLinkageFormatter ?? (_resourceLinkageFormatter = new ResourceLinkageFormatter()); + } + set + { + if (_resourceLinkageFormatter != null) throw new InvalidOperationException("This property can only be set once."); + _resourceLinkageFormatter = value; + } + } + + private IMetadataFormatter MetadataFormatter + { + get + { + return _metadataFormatter ?? (_metadataFormatter = new MetadataFormatter()); + } + set + { + if (_metadataFormatter != null) throw new InvalidOperationException("This property can only be set once."); + _metadataFormatter = value; + } + } + public Task Serialize(IRelationshipObject relationshipObject, JsonWriter writer) { if (relationshipObject.Metadata == null && relationshipObject.SelfLink == null && @@ -60,12 +107,12 @@ protected virtual void SerializeLinks(IRelationshipObject relationshipObject, Js if (relationshipObject.SelfLink != null) { writer.WritePropertyName(SelfLinkKeyName); - _linkFormatter.Serialize(relationshipObject.SelfLink, writer); + LinkFormatter.Serialize(relationshipObject.SelfLink, writer); } if (relationshipObject.RelatedResourceLink != null) { writer.WritePropertyName(RelatedLinkKeyName); - _linkFormatter.Serialize(relationshipObject.RelatedResourceLink, writer); + LinkFormatter.Serialize(relationshipObject.RelatedResourceLink, writer); } writer.WriteEndObject(); @@ -80,7 +127,7 @@ protected virtual void SerializeLinkage(IRelationshipObject relationshipObject, if (relationshipObject.Linkage != null) { writer.WritePropertyName(LinkageKeyName); - _resourceLinkageFormatter.Serialize(relationshipObject.Linkage, writer); + ResourceLinkageFormatter.Serialize(relationshipObject.Linkage, writer); } } @@ -92,7 +139,7 @@ protected virtual void SerializeMetadata(IRelationshipObject relationshipObject, if (relationshipObject.Metadata != null) { writer.WritePropertyName(MetaKeyName); - _metadataFormatter.Serialize(relationshipObject.Metadata, writer); + MetadataFormatter.Serialize(relationshipObject.Metadata, writer); } } @@ -114,10 +161,10 @@ public async Task Deserialize(JsonReader reader, string cur switch (propertyName) { case LinkageKeyName: - linkage = await _resourceLinkageFormatter.Deserialize(reader, currentPath + "/" + LinkageKeyName); + linkage = await ResourceLinkageFormatter.Deserialize(reader, currentPath + "/" + LinkageKeyName); break; case MetaKeyName: - metadata = await _metadataFormatter.Deserialize(reader, currentPath + "/" + MetaKeyName); + metadata = await MetadataFormatter.Deserialize(reader, currentPath + "/" + MetaKeyName); break; } } diff --git a/JSONAPI/Json/ResourceCollectionDocumentFormatter.cs b/JSONAPI/Json/ResourceCollectionDocumentFormatter.cs index db2d40e9..b7a0c999 100644 --- a/JSONAPI/Json/ResourceCollectionDocumentFormatter.cs +++ b/JSONAPI/Json/ResourceCollectionDocumentFormatter.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -11,14 +12,22 @@ namespace JSONAPI.Json /// public class ResourceCollectionDocumentFormatter : IResourceCollectionDocumentFormatter { - private readonly IResourceObjectFormatter _resourceObjectFormatter; - private readonly IMetadataFormatter _metadataFormatter; + private IResourceObjectFormatter _resourceObjectFormatter; + private IMetadataFormatter _metadataFormatter; private const string PrimaryDataKeyName = "data"; private const string RelatedDataKeyName = "included"; private const string MetaKeyName = "meta"; - + /// - /// Creates a SingleResourceDocumentFormatter + /// Creates a ResourceCollectionDocumentFormatter + /// + public ResourceCollectionDocumentFormatter() + { + + } + + /// + /// Creates a ResourceCollectionDocumentFormatter /// /// /// @@ -28,16 +37,56 @@ public ResourceCollectionDocumentFormatter(IResourceObjectFormatter resourceObje _metadataFormatter = metadataFormatter; } + /// + /// The formatter to use for resource objects found in this document + /// + /// + public IResourceObjectFormatter ResourceObjectFormatter + { + get + { + return _resourceObjectFormatter ?? (_resourceObjectFormatter = new ResourceObjectFormatter()); + } + set + { + if (_resourceObjectFormatter != null) throw new InvalidOperationException("This property can only be set once."); + _resourceObjectFormatter = value; + } + } + + /// + /// The formatter to use for document-level metadata + /// + /// + public IMetadataFormatter MetadataFormatter + { + get + { + return _metadataFormatter ?? (_metadataFormatter = new MetadataFormatter()); + } + set + { + if (_metadataFormatter != null) throw new InvalidOperationException("This property can only be set once."); + _metadataFormatter = value; + } + } + public Task Serialize(IResourceCollectionDocument document, JsonWriter writer) { writer.WriteStartObject(); + if (document.Metadata != null) + { + writer.WritePropertyName(MetaKeyName); + MetadataFormatter.Serialize(document.Metadata, writer); + } + writer.WritePropertyName(PrimaryDataKeyName); writer.WriteStartArray(); foreach (var resourceObject in document.PrimaryData) { - _resourceObjectFormatter.Serialize(resourceObject, writer); + ResourceObjectFormatter.Serialize(resourceObject, writer); } writer.WriteEndArray(); @@ -47,16 +96,12 @@ public Task Serialize(IResourceCollectionDocument document, JsonWriter writer) writer.WriteStartArray(); foreach (var resourceObject in document.RelatedData) { - _resourceObjectFormatter.Serialize(resourceObject, writer); + ResourceObjectFormatter.Serialize(resourceObject, writer); } writer.WriteEndArray(); } - if (document.Metadata != null) - { - writer.WritePropertyName(MetaKeyName); - _metadataFormatter.Serialize(document.Metadata, writer); - } + writer.WriteEndObject(); @@ -91,7 +136,7 @@ public async Task Deserialize(JsonReader reader, st primaryData = await DeserializePrimaryData(reader, currentPath + "/" + PrimaryDataKeyName); break; case MetaKeyName: - metadata = await _metadataFormatter.Deserialize(reader, currentPath + "/" + MetaKeyName); + metadata = await MetadataFormatter.Deserialize(reader, currentPath + "/" + MetaKeyName); break; default: reader.Skip(); @@ -115,7 +160,7 @@ private async Task DeserializePrimaryData(JsonReader reader, if (reader.TokenType == JsonToken.EndArray) break; - var resourceObject = await _resourceObjectFormatter.Deserialize(reader, currentPath + "/" + index); + var resourceObject = await ResourceObjectFormatter.Deserialize(reader, currentPath + "/" + index); primaryData.Add(resourceObject); index++; diff --git a/JSONAPI/Json/ResourceObjectFormatter.cs b/JSONAPI/Json/ResourceObjectFormatter.cs index 076f0e0f..18907217 100644 --- a/JSONAPI/Json/ResourceObjectFormatter.cs +++ b/JSONAPI/Json/ResourceObjectFormatter.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using JSONAPI.Documents; @@ -12,9 +13,9 @@ namespace JSONAPI.Json /// public class ResourceObjectFormatter : IResourceObjectFormatter { - private readonly IRelationshipObjectFormatter _relationshipObjectFormatter; - private readonly ILinkFormatter _linkFormatter; - private readonly IMetadataFormatter _metadataFormatter; + private IRelationshipObjectFormatter _relationshipObjectFormatter; + private ILinkFormatter _linkFormatter; + private IMetadataFormatter _metadataFormatter; private const string TypeKeyName = "type"; private const string IdKeyName = "id"; private const string AttributesKeyName = "attributes"; @@ -24,7 +25,15 @@ public class ResourceObjectFormatter : IResourceObjectFormatter private const string SelfLinkKeyName = "self"; /// - /// Constructs a new resourceObjectFormatter + /// Constructs a new ResourceObjectFormatter + /// + public ResourceObjectFormatter() + { + + } + + /// + /// Constructs a new ResourceObjectFormatter /// /// The formatter to use for relationship objects /// The formatter to use for links @@ -36,6 +45,57 @@ public ResourceObjectFormatter(IRelationshipObjectFormatter relationshipObjectFo _metadataFormatter = metadataFormatter; } + /// + /// The formatter to use for relationship objects belonging to this resource + /// + /// + public IRelationshipObjectFormatter RelationshipObjectFormatter + { + get + { + return _relationshipObjectFormatter ?? (_relationshipObjectFormatter = new RelationshipObjectFormatter()); + } + set + { + if (_relationshipObjectFormatter != null) throw new InvalidOperationException("This property can only be set once."); + _relationshipObjectFormatter = value; + } + } + + /// + /// The formatter to use for links + /// + /// + public ILinkFormatter LinkFormatter + { + get + { + return _linkFormatter ?? (_linkFormatter = new LinkFormatter()); + } + set + { + if (_linkFormatter != null) throw new InvalidOperationException("This property can only be set once."); + _linkFormatter = value; + } + } + + /// + /// The formatter to use for metadata that belongs to this resource object + /// + /// + public IMetadataFormatter MetadataFormatter + { + get + { + return _metadataFormatter ?? (_metadataFormatter = new MetadataFormatter()); + } + set + { + if (_metadataFormatter != null) throw new InvalidOperationException("This property can only be set once."); + _metadataFormatter = value; + } + } + public Task Serialize(IResourceObject resourceObject, JsonWriter writer) { if (resourceObject == null) @@ -88,7 +148,7 @@ public Task Serialize(IResourceObject resourceObject, JsonWriter writer) { if (relationship.Value == null) continue; writer.WritePropertyName(relationship.Key); - _relationshipObjectFormatter.Serialize(relationship.Value, writer); + RelationshipObjectFormatter.Serialize(relationship.Value, writer); } writer.WriteEndObject(); } @@ -99,14 +159,14 @@ public Task Serialize(IResourceObject resourceObject, JsonWriter writer) writer.WritePropertyName(LinksKeyName); writer.WriteStartObject(); writer.WritePropertyName(SelfLinkKeyName); - _linkFormatter.Serialize(resourceObject.SelfLink, writer); + LinkFormatter.Serialize(resourceObject.SelfLink, writer); writer.WriteEndObject(); } if (resourceObject.Metadata != null) { writer.WritePropertyName(MetaKeyName); - _metadataFormatter.Serialize(resourceObject.Metadata, writer); + MetadataFormatter.Serialize(resourceObject.Metadata, writer); } writer.WriteEndObject(); @@ -142,7 +202,7 @@ public async Task Deserialize(JsonReader reader, string current id = (string) reader.Value; break; case MetaKeyName: - metadata = await _metadataFormatter.Deserialize(reader, currentPath + "/" + MetaKeyName); + metadata = await MetadataFormatter.Deserialize(reader, currentPath + "/" + MetaKeyName); break; case AttributesKeyName: attributes = DeserializeAttributes(reader, currentPath + "/" + AttributesKeyName); @@ -202,7 +262,7 @@ private async Task> DeserializeRelation var relationshipName = (string)reader.Value; reader.Read(); - var relationship = await _relationshipObjectFormatter.Deserialize(reader, currentPath + "/" + relationshipName); + var relationship = await RelationshipObjectFormatter.Deserialize(reader, currentPath + "/" + relationshipName); relationships.Add(relationshipName, relationship); } diff --git a/JSONAPI/Json/SingleResourceDocumentFormatter.cs b/JSONAPI/Json/SingleResourceDocumentFormatter.cs index e0414c11..816e963c 100644 --- a/JSONAPI/Json/SingleResourceDocumentFormatter.cs +++ b/JSONAPI/Json/SingleResourceDocumentFormatter.cs @@ -1,3 +1,4 @@ +using System; using System.Linq; using System.Threading.Tasks; using JSONAPI.Documents; @@ -10,12 +11,19 @@ namespace JSONAPI.Json /// public class SingleResourceDocumentFormatter : ISingleResourceDocumentFormatter { - private readonly IResourceObjectFormatter _resourceObjectFormatter; - private readonly IMetadataFormatter _metadataFormatter; + private IResourceObjectFormatter _resourceObjectFormatter; + private IMetadataFormatter _metadataFormatter; private const string PrimaryDataKeyName = "data"; private const string RelatedDataKeyName = "included"; private const string MetaKeyName = "meta"; + /// + /// Creates a SingleResourceDocumentFormatter with default parameters + /// + public SingleResourceDocumentFormatter() + { + } + /// /// Creates a SingleResourceDocumentFormatter /// @@ -27,13 +35,47 @@ public SingleResourceDocumentFormatter(IResourceObjectFormatter resourceObjectFo _metadataFormatter = metadataFormatter; } + /// + /// The formatter to use for resource objects found in this document + /// + /// + public IResourceObjectFormatter ResourceObjectFormatter + { + get + { + return _resourceObjectFormatter ?? (_resourceObjectFormatter = new ResourceObjectFormatter()); + } + set + { + if (_resourceObjectFormatter != null) throw new InvalidOperationException("This property can only be set once."); + _resourceObjectFormatter = value; + } + } + + /// + /// The formatter to use for document-level metadata + /// + /// + public IMetadataFormatter MetadataFormatter + { + get + { + return _metadataFormatter ?? (_metadataFormatter = new MetadataFormatter()); + } + set + { + if (_metadataFormatter != null) throw new InvalidOperationException("This property can only be set once."); + _metadataFormatter = value; + } + } + public Task Serialize(ISingleResourceDocument document, JsonWriter writer) { writer.WriteStartObject(); writer.WritePropertyName(PrimaryDataKeyName); - _resourceObjectFormatter.Serialize(document.PrimaryData, writer); + ResourceObjectFormatter.Serialize(document.PrimaryData, writer); if (document.RelatedData != null && document.RelatedData.Any()) { @@ -41,7 +83,7 @@ public Task Serialize(ISingleResourceDocument document, JsonWriter writer) writer.WriteStartArray(); foreach (var resourceObject in document.RelatedData) { - _resourceObjectFormatter.Serialize(resourceObject, writer); + ResourceObjectFormatter.Serialize(resourceObject, writer); } writer.WriteEndArray(); } @@ -49,7 +91,7 @@ public Task Serialize(ISingleResourceDocument document, JsonWriter writer) if (document.Metadata != null) { writer.WritePropertyName(MetaKeyName); - _metadataFormatter.Serialize(document.Metadata, writer); + MetadataFormatter.Serialize(document.Metadata, writer); } writer.WriteEndObject(); @@ -83,7 +125,7 @@ public async Task Deserialize(JsonReader reader, string primaryData = await DeserializePrimaryData(reader, currentPath + "/" + PrimaryDataKeyName); break; case MetaKeyName: - metadata = await _metadataFormatter.Deserialize(reader, currentPath + "/" + MetaKeyName); + metadata = await MetadataFormatter.Deserialize(reader, currentPath + "/" + MetaKeyName); break; default: reader.Skip(); @@ -98,7 +140,7 @@ private async Task DeserializePrimaryData(JsonReader reader, st { if (reader.TokenType == JsonToken.Null) return null; - var primaryData = await _resourceObjectFormatter.Deserialize(reader, currentPath); + var primaryData = await ResourceObjectFormatter.Deserialize(reader, currentPath); return primaryData; } } diff --git a/JSONAPI/QueryableTransformers/DefaultFilteringTransformer.cs b/JSONAPI/QueryableTransformers/DefaultFilteringTransformer.cs index baf37b80..f449d1bb 100644 --- a/JSONAPI/QueryableTransformers/DefaultFilteringTransformer.cs +++ b/JSONAPI/QueryableTransformers/DefaultFilteringTransformer.cs @@ -2,12 +2,10 @@ using System.Globalization; using System.Linq; using System.Linq.Expressions; -using System.Net; using System.Net.Http; using System.Reflection; -using System.Web.Http; -using JSONAPI.ActionFilters; using JSONAPI.Core; +using JSONAPI.Documents.Builders; namespace JSONAPI.QueryableTransformers { @@ -79,40 +77,53 @@ private Expression GetPredicateBody(HttpRequestMessage request, ParameterExpress } catch (TypeRegistrationNotFoundException) { - throw new HttpResponseException(HttpStatusCode.BadRequest); + throw JsonApiException.CreateForBadRequest("No registration exists for the specified type"); } - var resourceTypeField = registration.GetFieldByName(filterField); - - var queryValue = queryPair.Value; - if (string.IsNullOrWhiteSpace(queryValue)) - queryValue = null; + var expr = GetPredicate(filterField, registration, param, queryPair.Value); + workingExpr = workingExpr == null ? expr : Expression.AndAlso(workingExpr, expr); + } - Expression expr = null; + return workingExpr ?? Expression.Constant(true); // No filters, so return everything + } - // See if it is a field property - var fieldModelProperty = resourceTypeField as ResourceTypeAttribute; - if (fieldModelProperty != null) - expr = GetPredicateBodyForField(fieldModelProperty, queryValue, param); + private Expression GetPredicate(string filterField, IResourceTypeRegistration registration, ParameterExpression param, string queryValue) + { + if (filterField == "id") + return GetPredicateBodyForProperty(registration.IdProperty, queryValue, param); - // See if it is a relationship property - var relationshipModelProperty = resourceTypeField as ResourceTypeRelationship; - if (relationshipModelProperty != null) - expr = GetPredicateBodyForRelationship(relationshipModelProperty, queryValue, param); + var resourceTypeField = registration.GetFieldByName(filterField); + if (resourceTypeField == null) + throw JsonApiException.CreateForBadRequest( + string.Format("No attribute {0} exists on the specified type.", filterField)); - if (expr == null) throw new HttpResponseException(HttpStatusCode.BadRequest); + if (string.IsNullOrWhiteSpace(queryValue)) + queryValue = null; - workingExpr = workingExpr == null ? expr : Expression.AndAlso(workingExpr, expr); - } + // See if it is a field property + var fieldModelProperty = resourceTypeField as ResourceTypeAttribute; + if (fieldModelProperty != null) + return GetPredicateBodyForField(fieldModelProperty, queryValue, param); - return workingExpr ?? Expression.Constant(true); // No filters, so return everything + // See if it is a relationship property + var relationshipModelProperty = resourceTypeField as ResourceTypeRelationship; + if (relationshipModelProperty != null) + return GetPredicateBodyForRelationship(relationshipModelProperty, queryValue, param); + + throw JsonApiException.CreateForBadRequest( + string.Format("The attribute {0} is unsupported for filtering.", filterField)); + } + + private Expression GetPredicateBodyForField(ResourceTypeAttribute resourceTypeAttribute, string queryValue, + ParameterExpression param) + { + return GetPredicateBodyForProperty(resourceTypeAttribute.Property, queryValue, param); } // ReSharper disable once FunctionComplexityOverflow // TODO: should probably break this method up - private Expression GetPredicateBodyForField(ResourceTypeAttribute resourceTypeAttribute, string queryValue, ParameterExpression param) + private Expression GetPredicateBodyForProperty(PropertyInfo prop, string queryValue, ParameterExpression param) { - var prop = resourceTypeAttribute.Property; var propertyType = prop.PropertyType; Expression expr; @@ -344,7 +355,7 @@ private Expression GetPredicateBodyForRelationship(ResourceTypeRelationship reso } catch (TypeRegistrationNotFoundException) { - throw new HttpResponseException(HttpStatusCode.BadRequest); + throw JsonApiException.CreateForBadRequest("No registration exists for the specified type"); } var prop = resourceTypeProperty.Property; diff --git a/JSONAPI/QueryableTransformers/DefaultSortingTransformer.cs b/JSONAPI/QueryableTransformers/DefaultSortingTransformer.cs index 32b012c1..2898a58b 100644 --- a/JSONAPI/QueryableTransformers/DefaultSortingTransformer.cs +++ b/JSONAPI/QueryableTransformers/DefaultSortingTransformer.cs @@ -2,9 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; -using System.Net.Http; using System.Reflection; -using JSONAPI.ActionFilters; using JSONAPI.Core; using JSONAPI.Documents.Builders; @@ -26,24 +24,12 @@ public DefaultSortingTransformer(IResourceTypeRegistry resourceTypeRegistry) _resourceTypeRegistry = resourceTypeRegistry; } - private const string SortQueryParamKey = "sort"; - - public IOrderedQueryable Sort(IQueryable query, HttpRequestMessage request) + public IOrderedQueryable Sort(IQueryable query, string[] sortExpressions) { - var queryParams = request.GetQueryNameValuePairs(); - var sortParam = queryParams.FirstOrDefault(kvp => kvp.Key == SortQueryParamKey); - - string[] sortExpressions; - if (sortParam.Key != SortQueryParamKey) - { - sortExpressions = new[] { "id" }; // We have to sort by something, so make it the ID. - } - else - { - sortExpressions = sortParam.Value.Split(','); - } + if (sortExpressions == null || sortExpressions.Length == 0) + sortExpressions = new [] { "id" }; - var selectors = new List>>>(); + var selectors = new List>(); var usedProperties = new Dictionary(); var registration = _resourceTypeRegistry.GetRegistrationForType(typeof (T)); @@ -91,24 +77,55 @@ public IOrderedQueryable Sort(IQueryable query, HttpRequestMessage requ string.Format("The attribute \"{0}\" was specified more than once.", fieldName), "sort"); usedProperties[property] = null; - sortValueExpression = Expression.Property(paramExpr, property); } - var selector = Expression.Lambda>(sortValueExpression, paramExpr); - selectors.Add(Tuple.Create(ascending, selector)); + var selector = GetSelector(paramExpr, sortValueExpression, !ascending); + selectors.Add(selector); } var firstSelector = selectors.First(); - IOrderedQueryable workingQuery = - firstSelector.Item1 - ? query.OrderBy(firstSelector.Item2) - : query.OrderByDescending(firstSelector.Item2); + IOrderedQueryable workingQuery = firstSelector.ApplyInitially(query); + return selectors.Skip(1).Aggregate(workingQuery, (current, selector) => selector.ApplySubsequently(current)); + } + + private ISelector GetSelector(ParameterExpression paramExpr, Expression sortValueExpression, bool isDescending) + { + var lambda = Expression.Lambda(sortValueExpression, paramExpr); + var selectorType = typeof (Selector<,>).MakeGenericType(typeof (T), sortValueExpression.Type); + var selector = Activator.CreateInstance(selectorType, isDescending, lambda); + return (ISelector)selector; + } + } + + internal interface ISelector + { + IOrderedQueryable ApplyInitially(IQueryable unsortedQuery); + IOrderedQueryable ApplySubsequently(IOrderedQueryable currentQuery); + } + + internal class Selector : ISelector + { + private readonly bool _isDescending; + private readonly Expression> _propertyAccessorExpression; + + public Selector(bool isDescending, Expression> propertyAccessorExpression) + { + _isDescending = isDescending; + _propertyAccessorExpression = propertyAccessorExpression; + } + + public IOrderedQueryable ApplyInitially(IQueryable unsortedQuery) + { + if (_isDescending) return unsortedQuery.OrderByDescending(_propertyAccessorExpression); + return unsortedQuery.OrderBy(_propertyAccessorExpression); + } - return selectors.Skip(1).Aggregate(workingQuery, - (current, selector) => - selector.Item1 ? current.ThenBy(selector.Item2) : current.ThenByDescending(selector.Item2)); + public IOrderedQueryable ApplySubsequently(IOrderedQueryable currentQuery) + { + if (_isDescending) return currentQuery.ThenByDescending(_propertyAccessorExpression); + return currentQuery.ThenBy(_propertyAccessorExpression); } } } diff --git a/JSONAPI/QueryableTransformers/IQueryableSortingTransformer.cs b/JSONAPI/QueryableTransformers/IQueryableSortingTransformer.cs index 64e06fbf..9583527c 100644 --- a/JSONAPI/QueryableTransformers/IQueryableSortingTransformer.cs +++ b/JSONAPI/QueryableTransformers/IQueryableSortingTransformer.cs @@ -12,9 +12,9 @@ public interface IQueryableSortingTransformer /// Sorts the provided queryable based on information from the request message. /// /// The input query - /// The request message + /// The expressions to sort by /// The element type of the query /// The sorted query - IOrderedQueryable Sort(IQueryable query, HttpRequestMessage request); + IOrderedQueryable Sort(IQueryable query, string[] sortExpressions); } } \ No newline at end of file diff --git a/README.md b/README.md index b75c7ca0..ee1dc2e9 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,11 @@ JSONAPI.NET =========== [![jsonapi MyGet Build Status](https://www.myget.org/BuildSource/Badge/jsonapi?identifier=caf48269-c15b-4850-a29e-b41a23d9854d)](https://www.myget.org/) -News ----- +_Deprecation notice_ +------------------ -The NuGet packages are out! -* [JSONAPI](https://www.nuget.org/packages/JSONAPI/) -* [JSONAPI.EntityFramework](https://www.nuget.org/packages/JSONAPI.EntityFramework/) +JSONAPI.NET is no longer actively maintained. It is not recommended for use in new projects. It does NOT work with .NET Core. Please consider [one of the other .NET server implementations](http://jsonapi.org/implementations/#server-libraries-net) instead. -JSON API Compliance ----------------------- - -The master branch is roughly compatible with the RC3 version of JSON API. The major missing feature is inclusion of related resources. Many changes made to the spec since RC3 are not yet available in this library. Full 1.0 compliance is planned, so stay tuned! What is JSONAPI.NET? ==================== @@ -64,3 +58,88 @@ A `JSONAPI.IMaterializer` object can be added to that `ApiController` to broker # Didn't I read something about using Entity Framework? The classes in the `JSONAPI.EntityFramework` namespace take great advantage of the patterns set out in the `JSONAPI` namespace. The `EntityFrameworkMaterializer` is an `IMaterializer` that can operate with your own `DbContext` class to retrieve objects by Id/Primary Key, and can retrieve and update existing objects from your context in a way that Entity Framework expects for change tracking…that means, in theory, you can use the provided `JSONAPI.EntityFramework.ApiController` base class to handle GET, PUT, POST, and DELETE without writing any additional code! You will still almost certainly subclass `ApiController` to implement your business logic, but that means you only have to worry about your business logic--not implementing the JSON API spec or messing with your persistence layer. + + + +# Configuration JSONAPI.EntityFramework + +- [ ] Add some hints about the configuration of JSONAPI.EntityFramework + +## Manipulate entities before JSONAPI.EntityFramework persists them +To change your entities before they get persisted you can extend the `EntityFrameworkDocumentMaterializer` class. You need to register your custom DocumentMaterializer in your `JsonApiConfiguration` like that: +```C# +configuration.RegisterEntityFrameworkResourceType(c =>c.UseDocumentMaterializer()); +``` +Afterwards you can override the `OnCreate`, `OnUpdate` or `OnDelete` methods in your `CustomDocumentMaterializer`. + +```C# +protected override async Task OnCreate(Task record) +{ + await base.OnUpdate(record); + var entity = await record; + entity.CreatedOn = DateTime.Now; + entity.CreatedBy = Principal?.Identity; +} +``` + +> :information_source: HINT: To get the `Principal` you can add the following part into your `Startup.cs` which registers the `Principal` in Autofac and define a constructor Parameter on your `CustomDocumentMaterializer` of type `IPrincipal`. + +```C# + +configurator.OnApplicationLifetimeScopeCreating(builder => +{ +// ... +builder.Register(ctx => HttpContext.Current.GetOwinContext()).As(); +builder.Register((c, p) => + { + var owin = c.Resolve(); + return owin.Authentication.User; + }) + .As() + .InstancePerRequest(); +} +``` + + +## Set the context path of JSONAPI.EntityFramework + +Per default the routes created for the registered models from EntityFramework will appear in root folder. This can conflict with folders of the file system or other routes you may want to serve from the same project. +To solve the issue we can create an instance of the `BaseUrlService` and put it in the configuration. +The `BaseUrlService` can be created with the context path parameter which will be used to register the routes and put into responses. + +```C# +var configuration = new JsonApiConfiguration(); +configuration.RegisterEntityFrameworkResourceType(); +// ... registration stuff you need + +// this makes JSONAPI.NET create the route 'api/posts' +configuration.CustomBaseUrlService = new BaseUrlService("api"); +``` + +## Set the public origin host of JSONAPI +Since JSONAPI.NET returns urls it could result wrong links if JSONAPI runs behind a reverse proxy. Configure the public origin address as follows: +```C# +var configuration = new JsonApiConfiguration(); +configuration.RegisterEntityFrameworkResourceType(); +// ... registration stuff you need + +// this makes JSONAPI.NET to set the urls in responses to https://api.example.com/posts +// Important: don't leave the second string parameter! see below. +configuration.CustomBaseUrlService = new BaseUrlService(new Uri("https://api.example.com/"), ""); + +// this can also be combined with the context paht for routing like that: +// this makes JSONAPI.NET create the route 'api/posts' and response urls to be https://api.example.com:9443/api/posts +configuration.CustomBaseUrlService = new BaseUrlService(new Uri("https://api.example.com:9443/"), "api"); + +``` + +# Metadata + + + +## Pagination + +### total-pages / total-count +When using Entity Framework you can register type `EntityFrameworkQueryableResourceCollectionDocumentBuilder` to enable the `total-pages` and `total-count` meta properties. +When pagination is used the `total-pages` and `total-count` meta properties are provided to indicate the number of pages and records to the client. +