From 6326917b7a01f3e9aa34c66cdea3fd9ae0332b44 Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Sat, 18 Feb 2017 14:37:17 -0600 Subject: [PATCH 01/15] fix content type negotiation test --- .../Formatters/JsonApiOutputFormatter.cs | 9 ++-- .../Spec/ContentNegotiation.cs | 44 +++++++++++++++++++ .../TodoItemsControllerTests.cs | 2 - 3 files changed, 49 insertions(+), 6 deletions(-) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Spec/ContentNegotiation.cs diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiOutputFormatter.cs b/src/JsonApiDotNetCore/Formatters/JsonApiOutputFormatter.cs index fd3eddeabd..7e457d98b9 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiOutputFormatter.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiOutputFormatter.cs @@ -34,22 +34,23 @@ public async Task WriteAsync(OutputFormatterWriteContext context) logger?.LogInformation("Formatting response as JSONAPI"); var response = context.HttpContext.Response; - using (var writer = context.WriterFactory(response.Body, Encoding.UTF8)) { var jsonApiContext = GetService(context); - + string responseContent; try { if(context.Object.GetType() == typeof(Error) || jsonApiContext.RequestEntity == null) { logger?.LogInformation("Response was not a JSONAPI entity. Serializing as plain JSON."); - responseContent = JsonConvert.SerializeObject(context.Object); } else + { + response.ContentType = "application/vnd.api+json"; responseContent = JsonApiSerializer.Serialize(context.Object, jsonApiContext); + } } catch(Exception e) { @@ -57,7 +58,7 @@ public async Task WriteAsync(OutputFormatterWriteContext context) responseContent = new Error("400", e.Message).GetJson(); response.StatusCode = 400; } - + await writer.WriteAsync(responseContent); await writer.FlushAsync(); } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Spec/ContentNegotiation.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Spec/ContentNegotiation.cs new file mode 100644 index 0000000000..aead1eeb28 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Spec/ContentNegotiation.cs @@ -0,0 +1,44 @@ +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using DotNetCoreDocs; +using DotNetCoreDocs.Models; +using DotNetCoreDocs.Writers; +using JsonApiDotNetCoreExample; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Spec +{ + [Collection("WebHostCollection")] + public class ContentNegotiation + { + private DocsFixture _fixture; + public ContentNegotiation(DocsFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task Server_Sends_Correct_ContentType_Header() + { + // arrange + var builder = new WebHostBuilder() + .UseStartup(); + var httpMethod = new HttpMethod("GET"); + var route = "/api/v1/todo-items"; + var description = new RequestProperties("Server Sends Correct Content Type Header"); + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(httpMethod, route); + + // act + var response = await client.SendAsync(request); + + // assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("application/vnd.api+json", response.Content.Headers.ContentType.ToString()); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/TodoItemsControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/TodoItemsControllerTests.cs index b0975839b8..ba489577d7 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/TodoItemsControllerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/TodoItemsControllerTests.cs @@ -190,7 +190,6 @@ public async Task Can_Post_TodoItem() var request = new HttpRequestMessage(httpMethod, route); request.Content = new StringContent(JsonConvert.SerializeObject(content)); - Console.WriteLine(">>>" + JsonConvert.SerializeObject(content)); request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); var description = new RequestProperties("Post TodoItem"); @@ -198,7 +197,6 @@ public async Task Can_Post_TodoItem() // Act var response = await _fixture.MakeRequest(description, request); var body = await response.Content.ReadAsStringAsync(); - Console.WriteLine(">>>>>>>>" + body + response.StatusCode); var deserializedBody = (TodoItem)JsonApiDeSerializer.Deserialize(body, _jsonApiContext); // Assert From 2dcceaf0f90a1d0471738f597151c036a725c161 Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Sat, 18 Feb 2017 16:32:29 -0600 Subject: [PATCH 02/15] test(content-negotiation): clients should not send media type parameters --- .../IApplicationBuilderExtensions.cs | 19 +++++++++++- .../Spec/ContentNegotiation.cs | 31 ++++++++++++++++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/JsonApiDotNetCore/Extensions/IApplicationBuilderExtensions.cs b/src/JsonApiDotNetCore/Extensions/IApplicationBuilderExtensions.cs index 2610ae8784..20a2968ac3 100644 --- a/src/JsonApiDotNetCore/Extensions/IApplicationBuilderExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/IApplicationBuilderExtensions.cs @@ -6,8 +6,25 @@ public static class IApplicationBuilderExtensions { public static IApplicationBuilder UseJsonApi(this IApplicationBuilder app) { + app.Use(async (context, next) => + { + var contentType = context.Request.ContentType; + if (contentType != null) + { + var contentTypeArr = contentType.Split(';'); + if (contentTypeArr[0] == "application/vnd.api+json" && contentTypeArr.Length == 2) + { + context.Response.StatusCode = 415; + context.Response.Body.Flush(); + return; + } + } + + await next.Invoke(); + }); + app.UseMvc(); - + return app; } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Spec/ContentNegotiation.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Spec/ContentNegotiation.cs index aead1eeb28..f902d9f664 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Spec/ContentNegotiation.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Spec/ContentNegotiation.cs @@ -1,11 +1,17 @@ +using System; +using System.Diagnostics.Contracts; +using System.Globalization; using System.Net; using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; using System.Threading.Tasks; using DotNetCoreDocs; using DotNetCoreDocs.Models; using DotNetCoreDocs.Writers; using JsonApiDotNetCoreExample; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Formatters.Internal; using Microsoft.AspNetCore.TestHost; using Xunit; @@ -32,7 +38,7 @@ public async Task Server_Sends_Correct_ContentType_Header() var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(httpMethod, route); - + // act var response = await client.SendAsync(request); @@ -40,5 +46,28 @@ public async Task Server_Sends_Correct_ContentType_Header() Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("application/vnd.api+json", response.Content.Headers.ContentType.ToString()); } + + [Fact] + public async Task Server_Responds_415_With_MediaType_Parameters() + { + // arrange + var builder = new WebHostBuilder() + .UseStartup(); + var httpMethod = new HttpMethod("GET"); + var route = "/api/v1/todo-items"; + var description = new RequestProperties("Server responds with 415 if request contains media type parameters"); + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(httpMethod, route); + request.Content = new StringContent(string.Empty); + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + request.Content.Headers.ContentType.CharSet="ISO-8859-4"; + + // act + var response = await client.SendAsync(request); + + // assert + Assert.Equal(HttpStatusCode.UnsupportedMediaType, response.StatusCode); + } } } From bcf8fbbc87c2aefadbd4918aa5c257ff83ad576e Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Sat, 18 Feb 2017 17:41:03 -0600 Subject: [PATCH 03/15] test(content-negotiation): respond 406 for bad accept header --- .../IApplicationBuilderExtensions.cs | 60 +++++++++++++++---- .../Spec/ContentNegotiation.cs | 32 ++++++++-- 2 files changed, 74 insertions(+), 18 deletions(-) diff --git a/src/JsonApiDotNetCore/Extensions/IApplicationBuilderExtensions.cs b/src/JsonApiDotNetCore/Extensions/IApplicationBuilderExtensions.cs index 20a2968ac3..2f0468bf23 100644 --- a/src/JsonApiDotNetCore/Extensions/IApplicationBuilderExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/IApplicationBuilderExtensions.cs @@ -1,4 +1,6 @@ using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; namespace JsonApiDotNetCore.Routing { @@ -8,24 +10,58 @@ public static IApplicationBuilder UseJsonApi(this IApplicationBuilder app) { app.Use(async (context, next) => { - var contentType = context.Request.ContentType; - if (contentType != null) + if (IsValid(context)) + await next.Invoke(); + }); + + app.UseMvc(); + + return app; + } + + private static bool IsValid(HttpContext context) + { + return IsValidContentTypeHeader(context) && IsValidAcceptHeader(context); + } + + private static bool IsValidContentTypeHeader(HttpContext context) + { + var contentType = context.Request.ContentType; + if (contentType != null && ContainsMediaTypeParameters(contentType)) + { + FlushResponse(context, 415); + return false; + } + return true; + } + + private static bool IsValidAcceptHeader(HttpContext context) + { + var acceptHeaders = new StringValues(); + if (context.Request.Headers.TryGetValue("Accept", out acceptHeaders)) + { + foreach (var acceptHeader in acceptHeaders) { - var contentTypeArr = contentType.Split(';'); - if (contentTypeArr[0] == "application/vnd.api+json" && contentTypeArr.Length == 2) + if (ContainsMediaTypeParameters(acceptHeader)) { - context.Response.StatusCode = 415; - context.Response.Body.Flush(); - return; + FlushResponse(context, 406); + return false; } } + } + return true; + } - await next.Invoke(); - }); - - app.UseMvc(); + private static bool ContainsMediaTypeParameters(string mediaType) + { + var mediaTypeArr = mediaType.Split(';'); + return (mediaTypeArr[0] == "application/vnd.api+json" && mediaTypeArr.Length == 2); + } - return app; + private static void FlushResponse(HttpContext context, int statusCode) + { + context.Response.StatusCode = statusCode; + context.Response.Body.Flush(); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Spec/ContentNegotiation.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Spec/ContentNegotiation.cs index f902d9f664..8c0d985901 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Spec/ContentNegotiation.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Spec/ContentNegotiation.cs @@ -1,17 +1,12 @@ -using System; -using System.Diagnostics.Contracts; -using System.Globalization; using System.Net; using System.Net.Http; using System.Net.Http.Headers; -using System.Text; using System.Threading.Tasks; using DotNetCoreDocs; using DotNetCoreDocs.Models; using DotNetCoreDocs.Writers; using JsonApiDotNetCoreExample; using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc.Formatters.Internal; using Microsoft.AspNetCore.TestHost; using Xunit; @@ -61,7 +56,7 @@ public async Task Server_Responds_415_With_MediaType_Parameters() var request = new HttpRequestMessage(httpMethod, route); request.Content = new StringContent(string.Empty); request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); - request.Content.Headers.ContentType.CharSet="ISO-8859-4"; + request.Content.Headers.ContentType.CharSet = "ISO-8859-4"; // act var response = await client.SendAsync(request); @@ -69,5 +64,30 @@ public async Task Server_Responds_415_With_MediaType_Parameters() // assert Assert.Equal(HttpStatusCode.UnsupportedMediaType, response.StatusCode); } + + [Fact] + public async Task ServerResponds_406_If_RequestAcceptHeader_Contains_MediaTypeParameters() + { + // arrange + var builder = new WebHostBuilder() + .UseStartup(); + var httpMethod = new HttpMethod("GET"); + var route = "/api/v1/todo-items"; + var description = new RequestProperties("Server responds with 406..."); + var server = new TestServer(builder); + var client = server.CreateClient(); + var acceptHeader = new MediaTypeWithQualityHeaderValue("application/vnd.api+json"); + acceptHeader.CharSet = "ISO-8859-4"; + client.DefaultRequestHeaders + .Accept + .Add(acceptHeader); + var request = new HttpRequestMessage(httpMethod, route); + + // act + var response = await client.SendAsync(request); + + // assert + Assert.Equal(HttpStatusCode.NotAcceptable, response.StatusCode); + } } } From f163fb416c101b2fd052efe92adb027d89dbb7a5 Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Sat, 18 Feb 2017 17:51:11 -0600 Subject: [PATCH 04/15] test(todo-items): verify relationships can be included --- .../TodoItemsControllerTests.cs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/TodoItemsControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/TodoItemsControllerTests.cs index ba489577d7..267caa9d6d 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/TodoItemsControllerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/TodoItemsControllerTests.cs @@ -64,6 +64,7 @@ public async Task Can_Get_TodoItems() Assert.True(deserializedBody.Count <= expectedEntitiesPerPage); } + [Fact] public async Task Can_Paginate_TodoItems() { @@ -153,6 +154,37 @@ public async Task Can_Get_TodoItem_ById() Assert.Equal(todoItem.Ordinal, deserializedBody.Ordinal); } + [Fact] + public async Task Can_Get_TodoItem_WithOwner() + { + // Arrange + var person = new Person(); + var todoItem = _todoItemFaker.Generate(); + todoItem.Owner = person; + _context.TodoItems.Add(todoItem); + _context.SaveChanges(); + + var httpMethod = new HttpMethod("GET"); + var route = $"/api/v1/todo-items/{todoItem.Id}?include=owner"; + + var description = new RequestProperties("Get TodoItem By Id", new Dictionary { + { "/todo-items/{id}", "TodoItem Id" }, + { "?include={relationship}", "Included Relationship" } + }); + + // Act + var response = await _fixture.MakeRequest(description, httpMethod, route); + var body = await response.Content.ReadAsStringAsync(); + var deserializedBody = (TodoItem)JsonApiDeSerializer.Deserialize(body, _jsonApiContext); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(person.Id, deserializedBody.OwnerId); + Assert.Equal(todoItem.Id, deserializedBody.Id); + Assert.Equal(todoItem.Description, deserializedBody.Description); + Assert.Equal(todoItem.Ordinal, deserializedBody.Ordinal); + } + [Fact] public async Task Can_Post_TodoItem() { From a12ff930544c223b7dd94e95fa35d7abea677ecb Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Mon, 20 Feb 2017 06:50:17 -0600 Subject: [PATCH 05/15] refactor(tests): rename integration tests to acceptance --- .../Spec/ContentNegotiation.cs | 2 +- .../TodoItemsControllerTests.cs | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) rename test/JsonApiDotNetCoreExampleTests/{IntegrationTests => Acceptance}/Spec/ContentNegotiation.cs (98%) rename test/JsonApiDotNetCoreExampleTests/{IntegrationTests => Acceptance}/TodoItemsControllerTests.cs (99%) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Spec/ContentNegotiation.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ContentNegotiation.cs similarity index 98% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/Spec/ContentNegotiation.cs rename to test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ContentNegotiation.cs index 8c0d985901..8fba782c8c 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Spec/ContentNegotiation.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ContentNegotiation.cs @@ -10,7 +10,7 @@ using Microsoft.AspNetCore.TestHost; using Xunit; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Spec +namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec { [Collection("WebHostCollection")] public class ContentNegotiation diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/TodoItemsControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs similarity index 99% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/TodoItemsControllerTests.cs rename to test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs index 267caa9d6d..7ece786cd5 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/TodoItemsControllerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs @@ -15,9 +15,8 @@ using Xunit; using JsonApiDotNetCore.Services; using JsonApiDotNetCore.Serialization; -using System; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests +namespace JsonApiDotNetCoreExampleTests.Acceptance { [Collection("WebHostCollection")] public class TodoItemControllerTests @@ -64,7 +63,6 @@ public async Task Can_Get_TodoItems() Assert.True(deserializedBody.Count <= expectedEntitiesPerPage); } - [Fact] public async Task Can_Paginate_TodoItems() { From a66ec1c6463eb47e6fc72b6d6a53aed34bd6a736 Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Mon, 20 Feb 2017 07:05:25 -0600 Subject: [PATCH 06/15] test(spec): server should return 400 on bad query --- src/JsonApiDotNetCore/Internal/Error.cs | 3 ++ .../Internal/Query/QuerySet.cs | 4 ++ .../Acceptance/Spec/QueryParameters.cs | 49 +++++++++++++++++++ 3 files changed, 56 insertions(+) create mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameters.cs diff --git a/src/JsonApiDotNetCore/Internal/Error.cs b/src/JsonApiDotNetCore/Internal/Error.cs index 734830b3df..cf1fd4247e 100644 --- a/src/JsonApiDotNetCore/Internal/Error.cs +++ b/src/JsonApiDotNetCore/Internal/Error.cs @@ -4,6 +4,9 @@ namespace JsonApiDotNetCore.Internal { public class Error { + public Error() + { } + public Error(string status, string title) { Status = status; diff --git a/src/JsonApiDotNetCore/Internal/Query/QuerySet.cs b/src/JsonApiDotNetCore/Internal/Query/QuerySet.cs index 0bbe44b985..25bb9f1417 100644 --- a/src/JsonApiDotNetCore/Internal/Query/QuerySet.cs +++ b/src/JsonApiDotNetCore/Internal/Query/QuerySet.cs @@ -45,12 +45,16 @@ private void BuildQuerySet(IQueryCollection query) if (pair.Key.StartsWith("include")) { IncludedRelationships = ParseIncludedRelationships(pair.Value); + continue; } if (pair.Key.StartsWith("page")) { PageQuery = ParsePageQuery(pair.Key, pair.Value); + continue; } + + throw new JsonApiException("400", $"{pair} is not a valid query."); } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameters.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameters.cs new file mode 100644 index 0000000000..64bf1d7a96 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameters.cs @@ -0,0 +1,49 @@ +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using DotNetCoreDocs; +using DotNetCoreDocs.Models; +using DotNetCoreDocs.Writers; +using JsonApiDotNetCoreExample; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Newtonsoft.Json; +using Xunit; +using JsonApiDotNetCore.Internal; + +namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec +{ + [Collection("WebHostCollection")] + public class QueryParameters + { + private DocsFixture _fixture; + public QueryParameters(DocsFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task Server_Returns_400_ForUnknownQueryParam() + { + // arrange + const string queryKey = "unknownKey"; + const string queryValue = "value"; + var builder = new WebHostBuilder() + .UseStartup(); + var httpMethod = new HttpMethod("GET"); + var route = $"/api/v1/todo-items?{queryKey}={queryValue}"; + var description = new RequestProperties("Server Returns 400 For Unknown Query Params"); + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(httpMethod, route); + + // act + var response = await client.SendAsync(request); + var body = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); + + // assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal($"[{queryKey}, {queryValue}] is not a valid query.", body.Title); + } + } +} From 806f58fbcb7a90be065dcf46717dd16f1118bcc3 Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Mon, 20 Feb 2017 07:05:34 -0600 Subject: [PATCH 07/15] chore(project.json): bump version --- src/JsonApiDotNetCore/project.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/JsonApiDotNetCore/project.json b/src/JsonApiDotNetCore/project.json index e8384d48e3..8e362d4459 100644 --- a/src/JsonApiDotNetCore/project.json +++ b/src/JsonApiDotNetCore/project.json @@ -1,5 +1,5 @@ { - "version": "0.2.8", + "version": "0.2.9", "dependencies": { "Microsoft.NETCore.App": { From f285ee93db103a27cb925b010f48ef65ab5430dd Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Mon, 20 Feb 2017 07:17:22 -0600 Subject: [PATCH 08/15] refactor(middlware): split concerns move the middleware implementation into its own class, separate from the app builder extensions --- .../IApplicationBuilderExtensions.cs | 54 +-------------- .../Middleware/RequestMiddleware.cs | 67 +++++++++++++++++++ 2 files changed, 69 insertions(+), 52 deletions(-) create mode 100644 src/JsonApiDotNetCore/Middleware/RequestMiddleware.cs diff --git a/src/JsonApiDotNetCore/Extensions/IApplicationBuilderExtensions.cs b/src/JsonApiDotNetCore/Extensions/IApplicationBuilderExtensions.cs index 2f0468bf23..0a83c12895 100644 --- a/src/JsonApiDotNetCore/Extensions/IApplicationBuilderExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/IApplicationBuilderExtensions.cs @@ -1,6 +1,5 @@ +using JsonApiDotNetCore.Middleware; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Primitives; namespace JsonApiDotNetCore.Routing { @@ -8,60 +7,11 @@ public static class IApplicationBuilderExtensions { public static IApplicationBuilder UseJsonApi(this IApplicationBuilder app) { - app.Use(async (context, next) => - { - if (IsValid(context)) - await next.Invoke(); - }); + app.UseMiddleware(); app.UseMvc(); return app; } - - private static bool IsValid(HttpContext context) - { - return IsValidContentTypeHeader(context) && IsValidAcceptHeader(context); - } - - private static bool IsValidContentTypeHeader(HttpContext context) - { - var contentType = context.Request.ContentType; - if (contentType != null && ContainsMediaTypeParameters(contentType)) - { - FlushResponse(context, 415); - return false; - } - return true; - } - - private static bool IsValidAcceptHeader(HttpContext context) - { - var acceptHeaders = new StringValues(); - if (context.Request.Headers.TryGetValue("Accept", out acceptHeaders)) - { - foreach (var acceptHeader in acceptHeaders) - { - if (ContainsMediaTypeParameters(acceptHeader)) - { - FlushResponse(context, 406); - return false; - } - } - } - return true; - } - - private static bool ContainsMediaTypeParameters(string mediaType) - { - var mediaTypeArr = mediaType.Split(';'); - return (mediaTypeArr[0] == "application/vnd.api+json" && mediaTypeArr.Length == 2); - } - - private static void FlushResponse(HttpContext context, int statusCode) - { - context.Response.StatusCode = statusCode; - context.Response.Body.Flush(); - } } } diff --git a/src/JsonApiDotNetCore/Middleware/RequestMiddleware.cs b/src/JsonApiDotNetCore/Middleware/RequestMiddleware.cs new file mode 100644 index 0000000000..036497333f --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/RequestMiddleware.cs @@ -0,0 +1,67 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; + +namespace JsonApiDotNetCore.Middleware +{ + public class RequestMiddleware + { + private readonly RequestDelegate _next; + + public RequestMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task Invoke(HttpContext context) + { + if (IsValid(context)) + await _next(context); + } + + private static bool IsValid(HttpContext context) + { + return IsValidContentTypeHeader(context) && IsValidAcceptHeader(context); + } + + private static bool IsValidContentTypeHeader(HttpContext context) + { + var contentType = context.Request.ContentType; + if (contentType != null && ContainsMediaTypeParameters(contentType)) + { + FlushResponse(context, 415); + return false; + } + return true; + } + + private static bool IsValidAcceptHeader(HttpContext context) + { + var acceptHeaders = new StringValues(); + if (context.Request.Headers.TryGetValue("Accept", out acceptHeaders)) + { + foreach (var acceptHeader in acceptHeaders) + { + if (ContainsMediaTypeParameters(acceptHeader)) + { + FlushResponse(context, 406); + return false; + } + } + } + return true; + } + + private static bool ContainsMediaTypeParameters(string mediaType) + { + var mediaTypeArr = mediaType.Split(';'); + return (mediaTypeArr[0] == "application/vnd.api+json" && mediaTypeArr.Length == 2); + } + + private static void FlushResponse(HttpContext context, int statusCode) + { + context.Response.StatusCode = statusCode; + context.Response.Body.Flush(); + } + } +} From 28968509ec697d98026aa5714422e872dfaf1cb8 Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Mon, 20 Feb 2017 07:38:52 -0600 Subject: [PATCH 09/15] fix(errors): error objects should be a collection --- .../Formatters/JsonApiOutputFormatter.cs | 46 +++++++++++++------ src/JsonApiDotNetCore/Internal/Error.cs | 7 --- .../Internal/ErrorCollection.cs | 27 +++++++++++ .../Acceptance/Spec/QueryParameters.cs | 5 +- 4 files changed, 61 insertions(+), 24 deletions(-) create mode 100644 src/JsonApiDotNetCore/Internal/ErrorCollection.cs diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiOutputFormatter.cs b/src/JsonApiDotNetCore/Formatters/JsonApiOutputFormatter.cs index 7e457d98b9..05f9c57c4a 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiOutputFormatter.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiOutputFormatter.cs @@ -37,30 +37,24 @@ public async Task WriteAsync(OutputFormatterWriteContext context) using (var writer = context.WriterFactory(response.Body, Encoding.UTF8)) { var jsonApiContext = GetService(context); - + + response.ContentType = "application/vnd.api+json"; string responseContent; try { - if(context.Object.GetType() == typeof(Error) || jsonApiContext.RequestEntity == null) - { - logger?.LogInformation("Response was not a JSONAPI entity. Serializing as plain JSON."); - responseContent = JsonConvert.SerializeObject(context.Object); - } - else - { - response.ContentType = "application/vnd.api+json"; - responseContent = JsonApiSerializer.Serialize(context.Object, jsonApiContext); - } + responseContent = GetResponseBody(context.Object, jsonApiContext, logger); } - catch(Exception e) + catch (Exception e) { logger?.LogError(new EventId(), e, "An error ocurred while formatting the response"); - responseContent = new Error("400", e.Message).GetJson(); + var errors = new ErrorCollection(); + errors.Add(new Error("400", e.Message)); + responseContent = errors.GetJson(); response.StatusCode = 400; } - + await writer.WriteAsync(responseContent); - await writer.FlushAsync(); + await writer.FlushAsync(); } } @@ -68,5 +62,27 @@ private T GetService(OutputFormatterWriteContext context) { return context.HttpContext.RequestServices.GetService(); } + + private string GetResponseBody(object responseObject, IJsonApiContext jsonApiContext, ILogger logger) + { + if (responseObject.GetType() == typeof(Error) || jsonApiContext.RequestEntity == null) + { + if (responseObject.GetType() == typeof(Error)) + { + var errors = new ErrorCollection(); + errors.Add((Error)responseObject); + return errors.GetJson(); + } + else + { + logger?.LogInformation("Response was not a JSONAPI entity. Serializing as plain JSON."); + return JsonConvert.SerializeObject(responseObject); + } + } + else + { + return JsonApiSerializer.Serialize(responseObject, jsonApiContext); + } + } } } diff --git a/src/JsonApiDotNetCore/Internal/Error.cs b/src/JsonApiDotNetCore/Internal/Error.cs index cf1fd4247e..01c4a26de0 100644 --- a/src/JsonApiDotNetCore/Internal/Error.cs +++ b/src/JsonApiDotNetCore/Internal/Error.cs @@ -28,12 +28,5 @@ public Error(string status, string title, string detail) [JsonProperty("status")] public string Status { get; set; } - - public string GetJson() - { - return JsonConvert.SerializeObject(this, new JsonSerializerSettings { - NullValueHandling = NullValueHandling.Ignore - }); - } } } diff --git a/src/JsonApiDotNetCore/Internal/ErrorCollection.cs b/src/JsonApiDotNetCore/Internal/ErrorCollection.cs new file mode 100644 index 0000000000..6e5c375da1 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/ErrorCollection.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace JsonApiDotNetCore.Internal +{ + public class ErrorCollection + { + public ErrorCollection() + { + Errors = new List(); + } + + public List Errors { get; set; } + + public void Add(Error error) + { + Errors.Add(error); + } + + public string GetJson() + { + return JsonConvert.SerializeObject(this, new JsonSerializerSettings { + NullValueHandling = NullValueHandling.Ignore + }); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameters.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameters.cs index 64bf1d7a96..a67b380838 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameters.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameters.cs @@ -39,11 +39,12 @@ public async Task Server_Returns_400_ForUnknownQueryParam() // act var response = await client.SendAsync(request); - var body = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); + var body = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); // assert Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - Assert.Equal($"[{queryKey}, {queryValue}] is not a valid query.", body.Title); + Assert.Equal(1, body.Errors.Count); + Assert.Equal($"[{queryKey}, {queryValue}] is not a valid query.", body.Errors[0].Title); } } } From 82aca7734ecb433c0e37f47f7734ec6b945ff432 Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Mon, 20 Feb 2017 07:55:48 -0600 Subject: [PATCH 10/15] test(sorting): resources can be sorted by attrs --- .../Acceptance/TodoItemsControllerTests.cs | 87 ++++++++++++++++++- 1 file changed, 85 insertions(+), 2 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs index 7ece786cd5..137658f6a6 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs @@ -119,10 +119,92 @@ public async Task Can_Filter_TodoItems() Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.NotEmpty(deserializedBody); - foreach(var todoItemResult in deserializedBody) + foreach (var todoItemResult in deserializedBody) Assert.Equal(todoItem.Ordinal, todoItemResult.Ordinal); } + [Fact] + public async Task Can_Sort_TodoItems_By_Ordinal_Ascending() + { + // Arrange + _context.TodoItems.RemoveRange(_context.TodoItems); + + const int numberOfItems = 5; + var person = new Person(); + + for (var i = 1; i < numberOfItems; i++) + { + var todoItem = _todoItemFaker.Generate(); + todoItem.Ordinal = i; + todoItem.Owner = person; + _context.TodoItems.Add(todoItem); + } + _context.SaveChanges(); + + var httpMethod = new HttpMethod("GET"); + var route = $"/api/v1/todo-items?sort=ordinal"; + + var description = new RequestProperties("Sort TodoItems Ascending", new Dictionary { + { "?sort=attr", "Sort on attribute" } + }); + + // Act + var response = await _fixture.MakeRequest(description, httpMethod, route); + var body = await response.Content.ReadAsStringAsync(); + var deserializedBody = JsonApiDeSerializer.DeserializeList(body, _jsonApiContext); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotEmpty(deserializedBody); + + var priorOrdinal = 0; + foreach (var todoItemResult in deserializedBody) + { + Assert.True(todoItemResult.Ordinal > priorOrdinal); + } + } + + [Fact] + public async Task Can_Sort_TodoItems_By_Ordinal_Descending() + { + // Arrange + _context.TodoItems.RemoveRange(_context.TodoItems); + + const int numberOfItems = 5; + var person = new Person(); + + for (var i = 1; i < numberOfItems; i++) + { + var todoItem = _todoItemFaker.Generate(); + todoItem.Ordinal = i; + todoItem.Owner = person; + _context.TodoItems.Add(todoItem); + } + _context.SaveChanges(); + + var httpMethod = new HttpMethod("GET"); + var route = $"/api/v1/todo-items?sort=-ordinal"; + + var description = new RequestProperties("Sort TodoItems Descending", new Dictionary { + { "?sort=-attr", "Sort on attribute" } + }); + + // Act + var response = await _fixture.MakeRequest(description, httpMethod, route); + var body = await response.Content.ReadAsStringAsync(); + var deserializedBody = JsonApiDeSerializer.DeserializeList(body, _jsonApiContext); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotEmpty(deserializedBody); + + var priorOrdinal = numberOfItems + 1; + foreach (var todoItemResult in deserializedBody) + { + Assert.True(todoItemResult.Ordinal < priorOrdinal); + } + } + [Fact] public async Task Can_Get_TodoItem_ById() { @@ -206,7 +288,8 @@ public async Task Can_Post_TodoItem() { owner = new { - data = new { + data = new + { type = "people", id = person.Id.ToString() } From b4bad4d373609fbc971265c2d753e560db7c65fd Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Mon, 20 Feb 2017 07:56:00 -0600 Subject: [PATCH 11/15] doc(readme): add sorting documentation --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index 218f2a575d..9b1ece84fd 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,16 @@ identifier): ?filter[attribute]=ge:value ``` + +## Sorting + +Resources can be sorted by an attribute: + +``` +?sort=attribute // ascending +?sort=-attribute // descending +``` + # Tests ## Running From c63fbb05e259178ce71ec5e1c7c14de82e335ba9 Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Mon, 20 Feb 2017 07:58:01 -0600 Subject: [PATCH 12/15] fix(tests): sort test not updating the test value --- .../Acceptance/TodoItemsControllerTests.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs index 137658f6a6..ec19f68d5b 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs @@ -157,10 +157,11 @@ public async Task Can_Sort_TodoItems_By_Ordinal_Ascending() Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.NotEmpty(deserializedBody); - var priorOrdinal = 0; + long priorOrdinal = 0; foreach (var todoItemResult in deserializedBody) { Assert.True(todoItemResult.Ordinal > priorOrdinal); + priorOrdinal = todoItemResult.Ordinal; } } @@ -198,10 +199,11 @@ public async Task Can_Sort_TodoItems_By_Ordinal_Descending() Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.NotEmpty(deserializedBody); - var priorOrdinal = numberOfItems + 1; + long priorOrdinal = numberOfItems + 1; foreach (var todoItemResult in deserializedBody) { Assert.True(todoItemResult.Ordinal < priorOrdinal); + priorOrdinal = todoItemResult.Ordinal; } } From d1acba96a9ff36d7dbe6ed7d6df0b55c04a7ae44 Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Mon, 20 Feb 2017 08:10:03 -0600 Subject: [PATCH 13/15] test(patch): resources can be patched --- .../Acceptance/TodoItemsControllerTests.cs | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs index ec19f68d5b..7b4cd5bf18 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs @@ -318,5 +318,53 @@ public async Task Can_Post_TodoItem() Assert.Equal(HttpStatusCode.Created, response.StatusCode); Assert.Equal(todoItem.Description, deserializedBody.Description); } + + [Fact] + public async Task Can_Patch_TodoItem() + { + // Arrange + var person = new Person(); + _context.People.Add(person); + _context.SaveChanges(); + + var todoItem = _todoItemFaker.Generate(); + todoItem.Owner = person; + _context.TodoItems.Add(todoItem); + _context.SaveChanges(); + + var newTodoItem = _todoItemFaker.Generate(); + + var content = new + { + data = new + { + type = "todo-items", + attributes = new + { + description = newTodoItem.Description, + ordinial = newTodoItem.Ordinal + } + } + }; + + var httpMethod = new HttpMethod("PATCH"); + var route = $"/api/v1/todo-items/{todoItem.Id}"; + + var request = new HttpRequestMessage(httpMethod, route); + request.Content = new StringContent(JsonConvert.SerializeObject(content)); + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + + var description = new RequestProperties("Patch TodoItem"); + + // Act + var response = await _fixture.MakeRequest(description, request); + var body = await response.Content.ReadAsStringAsync(); + var deserializedBody = (TodoItem)JsonApiDeSerializer.Deserialize(body, _jsonApiContext); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(newTodoItem.Description, deserializedBody.Description); + Assert.Equal(newTodoItem.Ordinal, deserializedBody.Ordinal); + } } } \ No newline at end of file From cb280c3c8c8f62d4cef62a4787fdf2aaa88d344a Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Mon, 20 Feb 2017 08:15:17 -0600 Subject: [PATCH 14/15] test(delete): resources can be deleted --- .../Acceptance/TodoItemsControllerTests.cs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs index 7b4cd5bf18..27d1f78f69 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs @@ -15,6 +15,7 @@ using Xunit; using JsonApiDotNetCore.Services; using JsonApiDotNetCore.Serialization; +using System.Linq; namespace JsonApiDotNetCoreExampleTests.Acceptance { @@ -366,5 +367,35 @@ public async Task Can_Patch_TodoItem() Assert.Equal(newTodoItem.Description, deserializedBody.Description); Assert.Equal(newTodoItem.Ordinal, deserializedBody.Ordinal); } + + [Fact] + public async Task Can_Delete_TodoItem() + { + // Arrange + var person = new Person(); + _context.People.Add(person); + _context.SaveChanges(); + + var todoItem = _todoItemFaker.Generate(); + todoItem.Owner = person; + _context.TodoItems.Add(todoItem); + _context.SaveChanges(); + + var httpMethod = new HttpMethod("DELETE"); + var route = $"/api/v1/todo-items/{todoItem.Id}"; + + var request = new HttpRequestMessage(httpMethod, route); + request.Content = new StringContent(string.Empty); + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + + var description = new RequestProperties("Delete TodoItem"); + + // Act + var response = await _fixture.MakeRequest(description, request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Null(_context.TodoItems.FirstOrDefault(t => t.Id == todoItem.Id)); + } } } \ No newline at end of file From 5a5eb410d223f6eaebae9cc5efab8277f5749c2b Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Mon, 20 Feb 2017 08:20:38 -0600 Subject: [PATCH 15/15] fix(tests): ordinal misspelled --- .../Acceptance/TodoItemsControllerTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs index 27d1f78f69..0c137a87bc 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs @@ -285,7 +285,7 @@ public async Task Can_Post_TodoItem() attributes = new { description = todoItem.Description, - ordinial = todoItem.Ordinal + ordinal = todoItem.Ordinal }, relationships = new { @@ -343,7 +343,7 @@ public async Task Can_Patch_TodoItem() attributes = new { description = newTodoItem.Description, - ordinial = newTodoItem.Ordinal + ordinal = newTodoItem.Ordinal } } };