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 17cde210..9eb3fa47 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/DocumentMaterializers/StarshipDocumentMaterializer.cs +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/DocumentMaterializers/StarshipDocumentMaterializer.cs @@ -62,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 5cb186b3..9fb646b3 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/DocumentMaterializers/StarshipOfficersRelatedResourceMaterializer.cs +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/DocumentMaterializers/StarshipOfficersRelatedResourceMaterializer.cs @@ -17,8 +17,9 @@ public class StarshipOfficersRelatedResourceMaterializer : EntityFrameworkToMany public StarshipOfficersRelatedResourceMaterializer(ResourceTypeRelationship relationship, DbContext dbContext, IQueryableResourceCollectionDocumentBuilder queryableResourceCollectionDocumentBuilder, ISortExpressionExtractor sortExpressionExtractor, + IIncludeExpressionExtractor includeExpressionExtractor, IResourceTypeRegistration primaryTypeRegistration) - : base(relationship, dbContext, queryableResourceCollectionDocumentBuilder, sortExpressionExtractor, 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 e937de35..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 @@ -159,6 +166,7 @@ protected override void Load(ContainerBuilder builder) // 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 cbe623ad..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,12 +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; /// @@ -36,61 +38,71 @@ public EntityFrameworkDocumentMaterializer( 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(); var sortExpressions = _sortExpressionExtractor.ExtractSortExpressions(request); - return _queryableResourceCollectionDocumentBuilder.BuildDocument(query, request, sortExpressions, cancellationToken); + 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; } @@ -107,59 +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); - var sortExpressions = _sortExpressionExtractor.ExtractSortExpressions(request); + await record; + } - return await _queryableResourceCollectionDocumentBuilder.BuildDocument(relatedResourceQuery, request, sortExpressions, 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) @@ -174,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 2a52338e..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 @@ -29,8 +30,9 @@ public EntityFrameworkToManyRelatedResourceDocumentMaterializer( DbContext dbContext, IQueryableResourceCollectionDocumentBuilder queryableResourceCollectionDocumentBuilder, ISortExpressionExtractor sortExpressionExtractor, + IIncludeExpressionExtractor includeExpressionExtractor, IResourceTypeRegistration primaryTypeRegistration) - : base(queryableResourceCollectionDocumentBuilder, sortExpressionExtractor) + : base(queryableResourceCollectionDocumentBuilder, sortExpressionExtractor, includeExpressionExtractor) { _relationship = relationship; _dbContext = dbContext; @@ -52,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 39850fc4..6b458e2a 100644 --- a/JSONAPI.Tests/ActionFilters/DefaultFilteringTransformerTests.cs +++ b/JSONAPI.Tests/ActionFilters/DefaultFilteringTransformerTests.cs @@ -559,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() { diff --git a/JSONAPI.Tests/Core/ResourceTypeRegistrarTests.cs b/JSONAPI.Tests/Core/ResourceTypeRegistrarTests.cs index 79d30e47..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] @@ -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 24943f4a..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, 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); @@ -44,11 +45,14 @@ public async Task Creates_single_resource_document_for_registered_non_collection 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 "}); + 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, mockSortExpressionExtractor.Object, mockBaseUrlService.Object); + mockQueryableDocumentBuilder.Object, mockResourceCollectionDocumentBuilder.Object, mockSortExpressionExtractor.Object, mockIncludeExpressionExtractor.Object, mockBaseUrlService.Object); var resultDocument = await fallbackDocumentBuilder.BuildDocument(objectContent, request, cancellationTokenSource.Token); // Assert @@ -75,12 +79,14 @@ public async Task Creates_resource_collection_document_for_queryables() 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, sortExpressions, cancellationTokenSource.Token, null)) + .Setup(b => b.BuildDocument(items, request, sortExpressions, cancellationTokenSource.Token, includeExpressions)) .Returns(Task.FromResult(mockDocument.Object)); var mockResourceCollectionDocumentBuilder = new Mock(MockBehavior.Strict); @@ -88,9 +94,12 @@ public async Task Creates_resource_collection_document_for_queryables() 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, mockSortExpressionExtractor.Object, mockBaseUrlService.Object); + mockQueryableDocumentBuilder.Object, mockResourceCollectionDocumentBuilder.Object, mockSortExpressionExtractor.Object, mockIncludeExpressionExtractor.Object, mockBaseUrlService.Object); var resultDocument = await fallbackDocumentBuilder.BuildDocument(items, request, cancellationTokenSource.Token); // Assert @@ -127,9 +136,12 @@ public async Task Creates_resource_collection_document_for_non_queryable_enumera 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, mockSortExpressionExtractor.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/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/JSONAPI.Tests.csproj b/JSONAPI.Tests/JSONAPI.Tests.csproj index 7b2fff70..7f487003 100644 --- a/JSONAPI.Tests/JSONAPI.Tests.csproj +++ b/JSONAPI.Tests/JSONAPI.Tests.csproj @@ -90,6 +90,8 @@ + + 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/EphemeralRelatedResourceReader.cs b/JSONAPI/Core/EphemeralRelatedResourceReader.cs index 9ef242d6..7f40ef7f 100644 --- a/JSONAPI/Core/EphemeralRelatedResourceReader.cs +++ b/JSONAPI/Core/EphemeralRelatedResourceReader.cs @@ -2,8 +2,6 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; -using System.Text; -using System.Threading.Tasks; using JSONAPI.Documents; using JSONAPI.Json; @@ -13,11 +11,34 @@ 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 Lazy _resourceTypeRegistration; private readonly MethodInfo _openSetToManyRelationshipValueMethod; /// @@ -29,14 +50,14 @@ public EphemeralRelatedResourceReader(IResourceTypeRegistry resourceTypeRegistry { _resourceTypeRegistry = resourceTypeRegistry; _ephemeralRelatedResourceCreator = ephemeralRelatedResourceCreator; - _resourceTypeRegistration = new Lazy(() => _resourceTypeRegistry.GetRegistrationForType(typeof(T))); _openSetToManyRelationshipValueMethod = GetType() .GetMethod("SetToManyRelationshipValue", BindingFlags.NonPublic | BindingFlags.Instance); } - public void SetProperty(T ephemeralResource, string jsonKey, IRelationshipObject relationshipObject) + public void SetProperty(T ephemeralResource, string jsonKey, IRelationshipObject relationshipObject) { - var relationship = _resourceTypeRegistration.Value.GetFieldByName(jsonKey) as ResourceTypeRelationship; + var resourceTypeRegistration = new Lazy(() => _resourceTypeRegistry.GetRegistrationForType(typeof(T))); + var relationship = resourceTypeRegistration.Value.GetFieldByName(jsonKey) as ResourceTypeRelationship; if (relationship == null) return; if (relationship.IsToMany) @@ -45,7 +66,7 @@ public void SetProperty(T ephemeralResource, string jsonKey, IRelationshipObject SetPropertyForToOneRelationship(ephemeralResource, relationship, relationshipObject.Linkage); } - protected virtual void SetPropertyForToOneRelationship(T ephemeralResource, ResourceTypeRelationship relationship, IResourceLinkage linkage) + protected virtual void SetPropertyForToOneRelationship(T ephemeralResource, ResourceTypeRelationship relationship, IResourceLinkage linkage) { if (linkage == null) throw new DeserializationException("Missing linkage for to-one relationship", @@ -70,7 +91,7 @@ protected virtual void SetPropertyForToOneRelationship(T ephemeralResource, Reso } } - protected virtual void SetPropertyForToManyRelationship(T ephemeralResource, ResourceTypeRelationship relationship, + protected virtual void SetPropertyForToManyRelationship(T ephemeralResource, ResourceTypeRelationship relationship, IResourceLinkage linkage) { if (linkage == null) diff --git a/JSONAPI/Core/IEphemeralRelatedResourceReader.cs b/JSONAPI/Core/IEphemeralRelatedResourceReader.cs index 827b455a..95a8fade 100644 --- a/JSONAPI/Core/IEphemeralRelatedResourceReader.cs +++ b/JSONAPI/Core/IEphemeralRelatedResourceReader.cs @@ -1,4 +1,5 @@ -using JSONAPI.Documents; +using System; +using JSONAPI.Documents; namespace JSONAPI.Core { @@ -6,6 +7,7 @@ namespace JSONAPI.Core /// Populates property values on an ephemeral resource from a relationship object /// /// + [Obsolete] public interface IEphemeralRelatedResourceReader { /// @@ -16,4 +18,18 @@ public interface IEphemeralRelatedResourceReader /// 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/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 d47e93c6..d0488b1c 100644 --- a/JSONAPI/Documents/Builders/DefaultQueryableResourceCollectionDocumentBuilder.cs +++ b/JSONAPI/Documents/Builders/DefaultQueryableResourceCollectionDocumentBuilder.cs @@ -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 f6882571..8208f274 100644 --- a/JSONAPI/Documents/Builders/FallbackDocumentBuilder.cs +++ b/JSONAPI/Documents/Builders/FallbackDocumentBuilder.cs @@ -18,6 +18,7 @@ public class FallbackDocumentBuilder : IFallbackDocumentBuilder 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; @@ -29,12 +30,14 @@ public FallbackDocumentBuilder(ISingleResourceDocumentBuilder singleResourceDocu IQueryableResourceCollectionDocumentBuilder queryableResourceCollectionDocumentBuilder, IResourceCollectionDocumentBuilder resourceCollectionDocumentBuilder, ISortExpressionExtractor sortExpressionExtractor, + IIncludeExpressionExtractor includeExpressionExtractor, IBaseUrlService baseUrlService) { _singleResourceDocumentBuilder = singleResourceDocumentBuilder; _queryableResourceCollectionDocumentBuilder = queryableResourceCollectionDocumentBuilder; _resourceCollectionDocumentBuilder = resourceCollectionDocumentBuilder; _sortExpressionExtractor = sortExpressionExtractor; + _includeExpressionExtractor = includeExpressionExtractor; _baseUrlService = baseUrlService; _openBuildDocumentFromQueryableMethod = @@ -53,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( @@ -66,7 +72,7 @@ public async Task BuildDocument(object obj, HttpRequestMessage var sortExpressions = _sortExpressionExtractor.ExtractSortExpressions(requestMessage); dynamic materializedQueryTask = buildDocumentMethod.Invoke(_queryableResourceCollectionDocumentBuilder, - new[] { obj, requestMessage, sortExpressions, cancellationToken, null }); + new[] { obj, requestMessage, sortExpressions, cancellationToken, includeExpressions }); return await materializedQueryTask; } @@ -89,7 +95,7 @@ public async Task BuildDocument(object obj, HttpRequestMessage } // 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/JsonApiException.cs b/JSONAPI/Documents/Builders/JsonApiException.cs index 00472ea0..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; } @@ -99,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/RegistryDrivenResourceCollectionDocumentBuilder.cs b/JSONAPI/Documents/Builders/RegistryDrivenResourceCollectionDocumentBuilder.cs index 031f9f9b..0508dd4a 100644 --- a/JSONAPI/Documents/Builders/RegistryDrivenResourceCollectionDocumentBuilder.cs +++ b/JSONAPI/Documents/Builders/RegistryDrivenResourceCollectionDocumentBuilder.cs @@ -27,8 +27,14 @@ public IResourceCollectionDocument BuildDocument(IEnumerable pri 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/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/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/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/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 ee002270..4c53c2e0 100644 --- a/JSONAPI/Http/MappedDocumentMaterializer.cs +++ b/JSONAPI/Http/MappedDocumentMaterializer.cs @@ -102,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 /// diff --git a/JSONAPI/Http/QueryableToManyRelatedResourceDocumentMaterializer.cs b/JSONAPI/Http/QueryableToManyRelatedResourceDocumentMaterializer.cs index 7d190960..6aa2286f 100644 --- a/JSONAPI/Http/QueryableToManyRelatedResourceDocumentMaterializer.cs +++ b/JSONAPI/Http/QueryableToManyRelatedResourceDocumentMaterializer.cs @@ -15,27 +15,36 @@ public abstract class QueryableToManyRelatedResourceDocumentMaterializer + /// List of includes given by url. + /// + protected string[] Includes = {}; /// /// Creates a new QueryableRelatedResourceDocumentMaterializer /// protected QueryableToManyRelatedResourceDocumentMaterializer( IQueryableResourceCollectionDocumentBuilder queryableResourceCollectionDocumentBuilder, - ISortExpressionExtractor sortExpressionExtractor) + 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(); 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 + + + return await _queryableResourceCollectionDocumentBuilder.BuildDocument(query, request, sortExpressions, cancellationToken, Includes); // TODO: allow implementors to specify metadata } /// @@ -43,15 +52,6 @@ public async Task GetRelatedResourceDocument(string primaryRes /// protected abstract Task> GetRelatedQuery(string primaryResourceId, CancellationToken cancellationToken); - /// - /// Gets a list of relationship paths to include - /// - /// - protected virtual string[] GetIncludePaths() - { - return null; - } - /// /// If the client doesn't request any sort expressions, these expressions will be used for sorting instead. /// diff --git a/JSONAPI/JSONAPI.csproj b/JSONAPI/JSONAPI.csproj index cfb6b1d5..9bd3d2e2 100644 --- a/JSONAPI/JSONAPI.csproj +++ b/JSONAPI/JSONAPI.csproj @@ -67,6 +67,7 @@ + @@ -81,6 +82,7 @@ + @@ -88,10 +90,13 @@ + + + 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 88e09605..f449d1bb 100644 --- a/JSONAPI/QueryableTransformers/DefaultFilteringTransformer.cs +++ b/JSONAPI/QueryableTransformers/DefaultFilteringTransformer.cs @@ -2,11 +2,8 @@ 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; @@ -83,41 +80,50 @@ private Expression GetPredicateBody(HttpRequestMessage request, ParameterExpress throw JsonApiException.CreateForBadRequest("No registration exists for the specified type"); } - var resourceTypeField = registration.GetFieldByName(filterField); - if (resourceTypeField == null) - throw JsonApiException.CreateForBadRequest( - string.Format("No attribute {0} exists on the specified type.", filterField)); + var expr = GetPredicate(filterField, registration, param, queryPair.Value); + workingExpr = workingExpr == null ? expr : Expression.AndAlso(workingExpr, expr); + } - var queryValue = queryPair.Value; - if (string.IsNullOrWhiteSpace(queryValue)) - queryValue = null; + return workingExpr ?? Expression.Constant(true); // No filters, so return everything + } - Expression expr = null; + 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 field property - var fieldModelProperty = resourceTypeField as ResourceTypeAttribute; - if (fieldModelProperty != null) - expr = GetPredicateBodyForField(fieldModelProperty, queryValue, param); + var resourceTypeField = registration.GetFieldByName(filterField); + if (resourceTypeField == null) + throw JsonApiException.CreateForBadRequest( + string.Format("No attribute {0} exists on the specified type.", filterField)); - // See if it is a relationship property - var relationshipModelProperty = resourceTypeField as ResourceTypeRelationship; - if (relationshipModelProperty != null) - expr = GetPredicateBodyForRelationship(relationshipModelProperty, queryValue, param); + if (string.IsNullOrWhiteSpace(queryValue)) + queryValue = null; - if (expr == null) throw JsonApiException.CreateForBadRequest( - string.Format("The attribute {0} is unsupported for filtering.", filterField)); + // See if it is a field property + var fieldModelProperty = resourceTypeField as ResourceTypeAttribute; + if (fieldModelProperty != null) + return GetPredicateBodyForField(fieldModelProperty, queryValue, param); - workingExpr = workingExpr == null ? expr : Expression.AndAlso(workingExpr, expr); - } + // See if it is a relationship property + var relationshipModelProperty = resourceTypeField as ResourceTypeRelationship; + if (relationshipModelProperty != null) + return GetPredicateBodyForRelationship(relationshipModelProperty, queryValue, param); - return workingExpr ?? Expression.Constant(true); // No filters, so return everything + 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; 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. +