diff --git a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs index 707d1d0944..f735e9c2ef 100644 --- a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs @@ -34,6 +34,11 @@ internal NamingStrategy SerializerNamingStrategy /// AttrCapabilities DefaultAttrCapabilities { get; } + /// + /// Indicates whether responses should contain a jsonapi object that contains the highest JSON:API version supported. False by default. + /// + bool IncludeJsonApiVersion { get; } + /// /// Whether or not stack traces should be serialized in objects. False by default. /// diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs index c1079fad58..8214b576ed 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs @@ -22,6 +22,9 @@ public sealed class JsonApiOptions : IJsonApiOptions /// public AttrCapabilities DefaultAttrCapabilities { get; set; } = AttrCapabilities.All; + /// + public bool IncludeJsonApiVersion { get; set; } + /// public bool IncludeExceptionStackTraceInErrors { get; set; } diff --git a/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs b/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs index aeb773dd88..00d28fe23f 100644 --- a/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs @@ -67,6 +67,18 @@ private string SerializeOperationsDocument(IEnumerable opera Meta = _metaBuilder.Build() }; + if (_options.IncludeJsonApiVersion) + { + document.JsonApi = new JsonApiObject + { + Version = "1.1", + Ext = new List + { + "https://jsonapi.org/ext/atomic" + } + }; + } + return SerializeObject(document, _options.SerializerSettings); } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationsDocument.cs b/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationsDocument.cs index 8d08f5fb31..b7352ed4c6 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationsDocument.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationsDocument.cs @@ -18,7 +18,7 @@ public sealed class AtomicOperationsDocument /// See "jsonapi" in https://jsonapi.org/format/#document-top-level. /// [JsonProperty("jsonapi", NullValueHandling = NullValueHandling.Ignore)] - public IDictionary JsonApi { get; set; } + public JsonApiObject JsonApi { get; set; } /// /// See "links" in https://jsonapi.org/format/#document-top-level. diff --git a/src/JsonApiDotNetCore/Serialization/Objects/Document.cs b/src/JsonApiDotNetCore/Serialization/Objects/Document.cs index 386309aedd..56c79f2b86 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/Document.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/Document.cs @@ -18,7 +18,7 @@ public sealed class Document : ExposableData /// see "jsonapi" in https://jsonapi.org/format/#document-top-level /// [JsonProperty("jsonapi", NullValueHandling = NullValueHandling.Ignore)] - public IDictionary JsonApi { get; set; } + public JsonApiObject JsonApi { get; set; } /// /// see "links" in https://jsonapi.org/format/#document-top-level diff --git a/src/JsonApiDotNetCore/Serialization/Objects/JsonApiObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/JsonApiObject.cs new file mode 100644 index 0000000000..66682fb6a2 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Objects/JsonApiObject.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace JsonApiDotNetCore.Serialization.Objects +{ + /// + /// https://jsonapi.org/format/1.1/#document-jsonapi-object. + /// + public sealed class JsonApiObject + { + [JsonProperty("version", NullValueHandling = NullValueHandling.Ignore)] + public string Version { get; set; } + + [JsonProperty("ext", NullValueHandling = NullValueHandling.Ignore)] + public ICollection Ext { get; set; } + + [JsonProperty("profile", NullValueHandling = NullValueHandling.Ignore)] + public ICollection Profile { get; set; } + + /// + /// see "meta" in https://jsonapi.org/format/1.1/#document-meta + /// + [JsonProperty("meta", NullValueHandling = NullValueHandling.Ignore)] + public IDictionary Meta { get; set; } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs b/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs index fb0b60f5fd..8e2dbd41f1 100644 --- a/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs @@ -149,6 +149,14 @@ internal string SerializeMany(IReadOnlyCollection resources) /// private void AddTopLevelObjects(Document document) { + if (_options.IncludeJsonApiVersion) + { + document.JsonApi = new JsonApiObject + { + Version = "1.1" + }; + } + document.Links = _linkBuilder.GetTopLevelLinks(); document.Meta = _metaBuilder.Build(); document.Included = _includedBuilder.Build(); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs new file mode 100644 index 0000000000..52dd8b2efa --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs @@ -0,0 +1,101 @@ +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCoreExample.Controllers; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Mixed +{ + public sealed class AtomicSerializationTests : IClassFixture, OperationsDbContext>> + { + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public AtomicSerializationTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); + }); + + var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); + options.IncludeJsonApiVersion = true; + options.AllowClientGeneratedIds = true; + } + + [Fact] + public async Task Includes_version_with_ext_on_operations_endpoint() + { + // Arrange + const int newArtistId = 12345; + string newArtistName = _fakers.Performer.Generate().ArtistName; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "performers", + id = newArtistId, + attributes = new + { + artistName = newArtistName + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Should().BeJson(@"{ + ""jsonapi"": { + ""version"": ""1.1"", + ""ext"": [ + ""https://jsonapi.org/ext/atomic"" + ] + }, + ""atomic:results"": [ + { + ""data"": { + ""type"": ""performers"", + ""id"": """ + newArtistId + @""", + ""attributes"": { + ""artistName"": """ + newArtistName + @""", + ""bornAt"": ""0001-01-01T01:00:00+01:00"" + }, + ""links"": { + ""self"": ""http://localhost/performers/" + newArtistId + @""" + } + } + } + ] +}"); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/SerializationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/SerializationTests.cs index c9e5c821d7..bd603c745d 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/SerializationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/SerializationTests.cs @@ -37,6 +37,7 @@ public SerializationTests(ExampleIntegrationTestContext(); options.IncludeExceptionStackTraceInErrors = false; options.AllowClientGeneratedIds = true; + options.IncludeJsonApiVersion = false; } [Fact] @@ -555,6 +556,40 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ""self"": ""http://localhost/meetingAttendees/" + existingAttendee.StringId + @""" } } +}"); + } + + [Fact] + public async Task Includes_version_on_resource_endpoint() + { + // Arrange + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.IncludeJsonApiVersion = true; + + MeetingAttendee attendee = _fakers.MeetingAttendee.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Attendees.Add(attendee); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/meetingAttendees/{attendee.StringId}/meeting"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Should().BeJson(@"{ + ""jsonapi"": { + ""version"": ""1.1"" + }, + ""links"": { + ""self"": ""http://localhost/meetingAttendees/" + attendee.StringId + @"/meeting"" + }, + ""data"": null }"); } }