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 diff --git a/src/JsonApiDotNetCore/Extensions/IApplicationBuilderExtensions.cs b/src/JsonApiDotNetCore/Extensions/IApplicationBuilderExtensions.cs index 2610ae8784..0a83c12895 100644 --- a/src/JsonApiDotNetCore/Extensions/IApplicationBuilderExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/IApplicationBuilderExtensions.cs @@ -1,3 +1,4 @@ +using JsonApiDotNetCore.Middleware; using Microsoft.AspNetCore.Builder; namespace JsonApiDotNetCore.Routing @@ -6,8 +7,10 @@ public static class IApplicationBuilderExtensions { public static IApplicationBuilder UseJsonApi(this IApplicationBuilder app) { + app.UseMiddleware(); + app.UseMvc(); - + return app; } } diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiOutputFormatter.cs b/src/JsonApiDotNetCore/Formatters/JsonApiOutputFormatter.cs index fd3eddeabd..05f9c57c4a 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiOutputFormatter.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiOutputFormatter.cs @@ -34,32 +34,27 @@ 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); + 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 - 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(); } } @@ -67,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 734830b3df..01c4a26de0 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; @@ -25,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/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/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(); + } + } +} 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": { diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ContentNegotiation.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ContentNegotiation.cs new file mode 100644 index 0000000000..8fba782c8c --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ContentNegotiation.cs @@ -0,0 +1,93 @@ +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +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.Acceptance.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()); + } + + [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); + } + + [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); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameters.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameters.cs new file mode 100644 index 0000000000..a67b380838 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameters.cs @@ -0,0 +1,50 @@ +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(1, body.Errors.Count); + Assert.Equal($"[{queryKey}, {queryValue}] is not a valid query.", body.Errors[0].Title); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/TodoItemsControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs similarity index 50% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/TodoItemsControllerTests.cs rename to test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs index b0975839b8..0c137a87bc 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/TodoItemsControllerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs @@ -15,9 +15,9 @@ using Xunit; using JsonApiDotNetCore.Services; using JsonApiDotNetCore.Serialization; -using System; +using System.Linq; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests +namespace JsonApiDotNetCoreExampleTests.Acceptance { [Collection("WebHostCollection")] public class TodoItemControllerTests @@ -120,10 +120,94 @@ 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); + + long priorOrdinal = 0; + foreach (var todoItemResult in deserializedBody) + { + Assert.True(todoItemResult.Ordinal > priorOrdinal); + priorOrdinal = todoItemResult.Ordinal; + } + } + + [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); + + long priorOrdinal = numberOfItems + 1; + foreach (var todoItemResult in deserializedBody) + { + Assert.True(todoItemResult.Ordinal < priorOrdinal); + priorOrdinal = todoItemResult.Ordinal; + } + } + [Fact] public async Task Can_Get_TodoItem_ById() { @@ -153,6 +237,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() { @@ -170,13 +285,14 @@ public async Task Can_Post_TodoItem() attributes = new { description = todoItem.Description, - ordinial = todoItem.Ordinal + ordinal = todoItem.Ordinal }, relationships = new { owner = new { - data = new { + data = new + { type = "people", id = person.Id.ToString() } @@ -190,7 +306,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,12 +313,89 @@ 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 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, + ordinal = 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); + } + + [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