Skip to content

Commit 62bfe8c

Browse files
lheiskanjaredcnance
authored andcommitted
feat/#226: Support for omitting null valued attributes from responses
- NullAttributeResponseBehavior option for configuration - Support for global defaults - Support for client override using a query string parameter
1 parent bea8c8a commit 62bfe8c

14 files changed

+272
-29
lines changed

JsonApiDotnetCore.sln

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
Microsoft Visual Studio Solution File, Format Version 12.00
22
# Visual Studio 15
3-
VisualStudioVersion = 15.0.26730.10
3+
VisualStudioVersion = 15.0.27004.2009
44
MinimumVisualStudioVersion = 10.0.40219.1
55
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "JsonApiDotNetCore", "src\JsonApiDotNetCore\JsonApiDotNetCore.csproj", "{C0EC9E70-EB2E-436F-9D94-FA16FA774123}"
66
EndProject
@@ -30,7 +30,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ReportsExample", "src\Examp
3030
EndProject
3131
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "benchmarks", "benchmarks", "{076E1AE4-FD25-4684-B826-CAAE37FEA0AA}"
3232
EndProject
33-
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Benchmarks", "benchmarks\Benchmarks.csproj", "{1F604666-BB0F-413E-922D-9D37C6073285}"
33+
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Benchmarks", "benchmarks\Benchmarks.csproj", "{1F604666-BB0F-413E-922D-9D37C6073285}"
3434
EndProject
3535
Global
3636
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -110,22 +110,22 @@ Global
110110
{FBFB0B0B-EA86-4B41-AB2A-E0249F70C86D}.Debug|x86.Build.0 = Debug|Any CPU
111111
{FBFB0B0B-EA86-4B41-AB2A-E0249F70C86D}.Release|Any CPU.ActiveCfg = Release|Any CPU
112112
{FBFB0B0B-EA86-4B41-AB2A-E0249F70C86D}.Release|Any CPU.Build.0 = Release|Any CPU
113-
{FBFB0B0B-EA86-4B41-AB2A-E0249F70C86D}.Release|x64.ActiveCfg = Release|x64
114-
{FBFB0B0B-EA86-4B41-AB2A-E0249F70C86D}.Release|x64.Build.0 = Release|x64
115-
{FBFB0B0B-EA86-4B41-AB2A-E0249F70C86D}.Release|x86.ActiveCfg = Release|x86
116-
{FBFB0B0B-EA86-4B41-AB2A-E0249F70C86D}.Release|x86.Build.0 = Release|x86
113+
{FBFB0B0B-EA86-4B41-AB2A-E0249F70C86D}.Release|x64.ActiveCfg = Release|Any CPU
114+
{FBFB0B0B-EA86-4B41-AB2A-E0249F70C86D}.Release|x64.Build.0 = Release|Any CPU
115+
{FBFB0B0B-EA86-4B41-AB2A-E0249F70C86D}.Release|x86.ActiveCfg = Release|Any CPU
116+
{FBFB0B0B-EA86-4B41-AB2A-E0249F70C86D}.Release|x86.Build.0 = Release|Any CPU
117117
{1F604666-BB0F-413E-922D-9D37C6073285}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
118118
{1F604666-BB0F-413E-922D-9D37C6073285}.Debug|Any CPU.Build.0 = Debug|Any CPU
119-
{1F604666-BB0F-413E-922D-9D37C6073285}.Debug|x64.ActiveCfg = Debug|x64
120-
{1F604666-BB0F-413E-922D-9D37C6073285}.Debug|x64.Build.0 = Debug|x64
121-
{1F604666-BB0F-413E-922D-9D37C6073285}.Debug|x86.ActiveCfg = Debug|x86
122-
{1F604666-BB0F-413E-922D-9D37C6073285}.Debug|x86.Build.0 = Debug|x86
119+
{1F604666-BB0F-413E-922D-9D37C6073285}.Debug|x64.ActiveCfg = Debug|Any CPU
120+
{1F604666-BB0F-413E-922D-9D37C6073285}.Debug|x64.Build.0 = Debug|Any CPU
121+
{1F604666-BB0F-413E-922D-9D37C6073285}.Debug|x86.ActiveCfg = Debug|Any CPU
122+
{1F604666-BB0F-413E-922D-9D37C6073285}.Debug|x86.Build.0 = Debug|Any CPU
123123
{1F604666-BB0F-413E-922D-9D37C6073285}.Release|Any CPU.ActiveCfg = Release|Any CPU
124124
{1F604666-BB0F-413E-922D-9D37C6073285}.Release|Any CPU.Build.0 = Release|Any CPU
125-
{1F604666-BB0F-413E-922D-9D37C6073285}.Release|x64.ActiveCfg = Release|x64
126-
{1F604666-BB0F-413E-922D-9D37C6073285}.Release|x64.Build.0 = Release|x64
127-
{1F604666-BB0F-413E-922D-9D37C6073285}.Release|x86.ActiveCfg = Release|x86
128-
{1F604666-BB0F-413E-922D-9D37C6073285}.Release|x86.Build.0 = Release|x86
125+
{1F604666-BB0F-413E-922D-9D37C6073285}.Release|x64.ActiveCfg = Release|Any CPU
126+
{1F604666-BB0F-413E-922D-9D37C6073285}.Release|x64.Build.0 = Release|Any CPU
127+
{1F604666-BB0F-413E-922D-9D37C6073285}.Release|x86.ActiveCfg = Release|Any CPU
128+
{1F604666-BB0F-413E-922D-9D37C6073285}.Release|x86.Build.0 = Release|Any CPU
129129
EndGlobalSection
130130
GlobalSection(SolutionProperties) = preSolution
131131
HideSolutionNode = FALSE

src/JsonApiDotNetCore/Builders/DocumentBuilder.cs

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,14 @@ public class DocumentBuilder : IDocumentBuilder
1313
private readonly IJsonApiContext _jsonApiContext;
1414
private readonly IContextGraph _contextGraph;
1515
private readonly IRequestMeta _requestMeta;
16+
private readonly DocumentBuilderOptions _documentBuilderOptions;
1617

17-
public DocumentBuilder(IJsonApiContext jsonApiContext)
18-
{
19-
_jsonApiContext = jsonApiContext;
20-
_contextGraph = jsonApiContext.ContextGraph;
21-
}
22-
23-
public DocumentBuilder(IJsonApiContext jsonApiContext, IRequestMeta requestMeta)
18+
public DocumentBuilder(IJsonApiContext jsonApiContext, IRequestMeta requestMeta=null, IDocumentBuilderOptionsProvider documentBuilderOptionsProvider=null)
2419
{
2520
_jsonApiContext = jsonApiContext;
2621
_contextGraph = jsonApiContext.ContextGraph;
2722
_requestMeta = requestMeta;
23+
_documentBuilderOptions = documentBuilderOptionsProvider?.GetDocumentBuilderOptions() ?? new DocumentBuilderOptions(); ;
2824
}
2925

3026
public Document Build(IIdentifiable entity)
@@ -118,8 +114,11 @@ private DocumentData GetData(ContextEntity contextEntity, IIdentifiable entity)
118114

119115
contextEntity.Attributes.ForEach(attr =>
120116
{
121-
if(ShouldIncludeAttribute(attr))
122-
data.Attributes.Add(attr.PublicAttributeName, attr.GetValue(entity));
117+
var attributeValue = attr.GetValue(entity);
118+
if (ShouldIncludeAttribute(attr, attributeValue))
119+
{
120+
data.Attributes.Add(attr.PublicAttributeName, attributeValue);
121+
}
123122
});
124123

125124
if (contextEntity.Relationships.Count > 0)
@@ -128,11 +127,17 @@ private DocumentData GetData(ContextEntity contextEntity, IIdentifiable entity)
128127
return data;
129128
}
130129

131-
private bool ShouldIncludeAttribute(AttrAttribute attr)
130+
private bool ShouldIncludeAttribute(AttrAttribute attr, object attributeValue)
131+
{
132+
return !OmitNullValuedAttribute(attr, attributeValue)
133+
&& ((_jsonApiContext.QuerySet == null
134+
|| _jsonApiContext.QuerySet.Fields.Count == 0)
135+
|| _jsonApiContext.QuerySet.Fields.Contains(attr.InternalAttributeName));
136+
}
137+
138+
private bool OmitNullValuedAttribute(AttrAttribute attr, object attributeValue)
132139
{
133-
return (_jsonApiContext.QuerySet == null
134-
|| _jsonApiContext.QuerySet.Fields.Count == 0
135-
|| _jsonApiContext.QuerySet.Fields.Contains(attr.InternalAttributeName));
140+
return attributeValue == null && _documentBuilderOptions.OmitNullValuedAttributes;
136141
}
137142

138143
private void AddRelationships(DocumentData data, ContextEntity contextEntity, IIdentifiable entity)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Text;
4+
5+
namespace JsonApiDotNetCore.Builders
6+
{
7+
public struct DocumentBuilderOptions
8+
{
9+
public DocumentBuilderOptions(bool omitNullValuedAttributes = false)
10+
{
11+
this.OmitNullValuedAttributes = omitNullValuedAttributes;
12+
}
13+
14+
public bool OmitNullValuedAttributes { get; private set; }
15+
}
16+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Text;
4+
using JsonApiDotNetCore.Services;
5+
using Microsoft.AspNetCore.Http;
6+
7+
namespace JsonApiDotNetCore.Builders
8+
{
9+
public class DocumentBuilderOptionsProvider : IDocumentBuilderOptionsProvider
10+
{
11+
private readonly IJsonApiContext _jsonApiContext;
12+
private readonly IHttpContextAccessor _httpContextAccessor;
13+
14+
public DocumentBuilderOptionsProvider(IJsonApiContext jsonApiContext, IHttpContextAccessor httpContextAccessor)
15+
{
16+
_jsonApiContext = jsonApiContext;
17+
_httpContextAccessor = httpContextAccessor;
18+
}
19+
20+
public DocumentBuilderOptions GetDocumentBuilderOptions()
21+
{
22+
var nullAttributeResponseBehaviorConfig = this._jsonApiContext.Options.NullAttributeResponseBehavior;
23+
if (nullAttributeResponseBehaviorConfig.AllowClientOverride && _httpContextAccessor.HttpContext.Request.Query.TryGetValue("omitNullValuedAttributes", out var omitNullValuedAttributesQs))
24+
{
25+
if (bool.TryParse(omitNullValuedAttributesQs, out var omitNullValuedAttributes))
26+
{
27+
return new DocumentBuilderOptions(omitNullValuedAttributes);
28+
}
29+
}
30+
return new DocumentBuilderOptions(this._jsonApiContext.Options.NullAttributeResponseBehavior.OmitNullValuedAttributes);
31+
}
32+
}
33+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Text;
4+
5+
namespace JsonApiDotNetCore.Builders
6+
{
7+
public interface IDocumentBuilderOptionsProvider
8+
{
9+
DocumentBuilderOptions GetDocumentBuilderOptions();
10+
}
11+
}

src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ public class JsonApiOptions
1717
public IContextGraph ContextGraph { get; set; }
1818
public bool RelativeLinks { get; set; }
1919
public bool AllowCustomQueryParameters { get; set; }
20+
public NullAttributeResponseBehavior NullAttributeResponseBehavior { get; set; }
2021

2122
[Obsolete("JsonContract resolver can now be set on SerializerSettings.")]
2223
public IContractResolver JsonContractResolver
@@ -29,6 +30,7 @@ public IContractResolver JsonContractResolver
2930
NullValueHandling = NullValueHandling.Ignore,
3031
ContractResolver = new DasherizedResolver()
3132
};
33+
3234
internal IContextGraphBuilder ContextGraphBuilder { get; } = new ContextGraphBuilder();
3335

3436
public void BuildContextGraph<TContext>(Action<IContextGraphBuilder> builder) where TContext : DbContext
@@ -49,4 +51,6 @@ public void BuildContextGraph(Action<IContextGraphBuilder> builder)
4951
ContextGraph = ContextGraphBuilder.Build();
5052
}
5153
}
54+
55+
5256
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Text;
4+
5+
namespace JsonApiDotNetCore.Configuration
6+
{
7+
public struct NullAttributeResponseBehavior
8+
{
9+
public NullAttributeResponseBehavior(bool omitNullValuedAttributes = false, bool allowClientOverride = false)
10+
{
11+
OmitNullValuedAttributes = omitNullValuedAttributes;
12+
AllowClientOverride = allowClientOverride;
13+
}
14+
15+
public bool OmitNullValuedAttributes { get; }
16+
public bool AllowClientOverride { get; }
17+
// ...
18+
}
19+
}

src/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ public static void AddJsonApiInternals(
112112
services.AddScoped<IQueryAccessor, QueryAccessor>();
113113
services.AddScoped<IQueryParser, QueryParser>();
114114
services.AddScoped<IControllerContext, Services.ControllerContext>();
115+
services.AddScoped<IDocumentBuilderOptionsProvider, DocumentBuilderOptionsProvider>();
115116
}
116117

117118
public static void SerializeAsJsonApi(this MvcOptions options, JsonApiOptions jsonApiOptions)

src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ public object Deserialize(string requestBody)
2929
try
3030
{
3131
var document = JsonConvert.DeserializeObject<Document>(requestBody);
32+
_jsonApiContext.DocumentMeta = document.Meta;
3233
var entity = DocumentToObject(document.Data);
3334
return entity;
3435
}
@@ -222,4 +223,4 @@ private object SetHasManyRelationship(object entity,
222223
return entity;
223224
}
224225
}
225-
}
226+
}

src/JsonApiDotNetCore/Services/IJsonApiContext.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ public interface IJsonApiContext
2626
Dictionary<AttrAttribute, object> AttributesToUpdate { get; set; }
2727
Dictionary<RelationshipAttribute, object> RelationshipsToUpdate { get; set; }
2828
Type ControllerType { get; set; }
29+
Dictionary<string, object> DocumentMeta { get; set; }
30+
2931
TAttribute GetControllerAttribute<TAttribute>() where TAttribute : Attribute;
3032
}
3133
}

src/JsonApiDotNetCore/Services/JsonApiContext.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ public JsonApiContext(
5050
public Dictionary<AttrAttribute, object> AttributesToUpdate { get; set; } = new Dictionary<AttrAttribute, object>();
5151
public Dictionary<RelationshipAttribute, object> RelationshipsToUpdate { get; set; } = new Dictionary<RelationshipAttribute, object>();
5252
public Type ControllerType { get; set; }
53+
public Dictionary<string, object> DocumentMeta { get; set; }
5354

5455
public IJsonApiContext ApplyContext<T>(object controller)
5556
{
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Text;
4+
using JsonApiDotNetCore.Builders;
5+
using JsonApiDotNetCore.Configuration;
6+
using JsonApiDotNetCore.Services;
7+
using Microsoft.AspNetCore.Http;
8+
using Moq;
9+
using Xunit;
10+
11+
namespace UnitTests.Builders
12+
{
13+
public class DocumentBuilderBehaviour_Tests
14+
{
15+
16+
[Theory]
17+
[InlineData(null, null, null, false)]
18+
[InlineData(false, null, null, false)]
19+
[InlineData(true, null, null, true)]
20+
[InlineData(false, false, "true", false)]
21+
[InlineData(false, true, "true", true)]
22+
[InlineData(true, true, "false", false)]
23+
[InlineData(true, false, "false", true)]
24+
[InlineData(null, false, "false", false)]
25+
[InlineData(null, false, "true", false)]
26+
[InlineData(null, true, "true", true)]
27+
[InlineData(null, true, "false", false)]
28+
[InlineData(null, true, "foo", false)]
29+
[InlineData(null, false, "foo", false)]
30+
[InlineData(true, true, "foo", true)]
31+
[InlineData(true, false, "foo", true)]
32+
[InlineData(null, true, null, false)]
33+
[InlineData(null, false, null, false)]
34+
public void CheckNullBehaviorCombination(bool? omitNullValuedAttributes, bool? allowClientOverride, string clientOverride, bool omitsNulls)
35+
{
36+
37+
NullAttributeResponseBehavior nullAttributeResponseBehavior;
38+
if (omitNullValuedAttributes.HasValue && allowClientOverride.HasValue)
39+
{
40+
nullAttributeResponseBehavior = new NullAttributeResponseBehavior(omitNullValuedAttributes.Value, allowClientOverride.Value);
41+
}else if (omitNullValuedAttributes.HasValue)
42+
{
43+
nullAttributeResponseBehavior = new NullAttributeResponseBehavior(omitNullValuedAttributes.Value);
44+
}else if
45+
(allowClientOverride.HasValue)
46+
{
47+
nullAttributeResponseBehavior = new NullAttributeResponseBehavior(allowClientOverride: allowClientOverride.Value);
48+
}
49+
else
50+
{
51+
nullAttributeResponseBehavior = new NullAttributeResponseBehavior();
52+
}
53+
54+
var jsonApiContextMock = new Mock<IJsonApiContext>();
55+
jsonApiContextMock.SetupGet(m => m.Options)
56+
.Returns(new JsonApiOptions() {NullAttributeResponseBehavior = nullAttributeResponseBehavior});
57+
58+
var httpContext = new DefaultHttpContext();
59+
if (clientOverride != null)
60+
{
61+
httpContext.Request.QueryString = new QueryString($"?omitNullValuedAttributes={clientOverride}");
62+
}
63+
var httpContextAccessorMock = new Mock<IHttpContextAccessor>();
64+
httpContextAccessorMock.SetupGet(m => m.HttpContext).Returns(httpContext);
65+
66+
var sut = new DocumentBuilderOptionsProvider(jsonApiContextMock.Object, httpContextAccessorMock.Object);
67+
var documentBuilderOptions = sut.GetDocumentBuilderOptions();
68+
69+
Assert.Equal(omitsNulls, documentBuilderOptions.OmitNullValuedAttributes);
70+
}
71+
72+
}
73+
}

test/UnitTests/Builders/DocumentBuilder_Tests.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@ public class DocumentBuilder_Tests
1616
private readonly Mock<IJsonApiContext> _jsonApiContextMock;
1717
private readonly PageManager _pageManager;
1818
private readonly JsonApiOptions _options;
19+
private readonly Mock<IRequestMeta> _requestMetaMock;
1920

2021
public DocumentBuilder_Tests()
2122
{
2223
_jsonApiContextMock = new Mock<IJsonApiContext>();
24+
_requestMetaMock = new Mock<IRequestMeta>();
2325

2426
_options = new JsonApiOptions();
2527

@@ -141,11 +143,38 @@ public void Build_Can_Build_CustomIEnumerables()
141143
Assert.Equal(1, documents.Data.Count);
142144
}
143145

146+
147+
[Theory]
148+
[InlineData(null,null,true)]
149+
[InlineData(false,null,true)]
150+
[InlineData(true,null,false)]
151+
[InlineData(null,"foo",true)]
152+
[InlineData(false,"foo",true)]
153+
[InlineData(true,"foo",true)]
154+
public void DocumentBuilderOptions(bool? omitNullValuedAttributes,
155+
string attributeValue,
156+
bool resultContainsAttribute)
157+
{
158+
var documentBuilderBehaviourMock = new Mock<IDocumentBuilderOptionsProvider>();
159+
if (omitNullValuedAttributes.HasValue)
160+
{
161+
documentBuilderBehaviourMock.Setup(m => m.GetDocumentBuilderOptions())
162+
.Returns(new DocumentBuilderOptions(omitNullValuedAttributes.Value));
163+
}
164+
var documentBuilder = new DocumentBuilder(_jsonApiContextMock.Object, null, omitNullValuedAttributes.HasValue ? documentBuilderBehaviourMock.Object : null);
165+
var document = documentBuilder.Build(new Model(){StringProperty = attributeValue});
166+
167+
Assert.Equal(resultContainsAttribute, document.Data.Attributes.ContainsKey("StringProperty"));
168+
}
169+
144170
private class Model : Identifiable
145171
{
146172
[HasOne("related-model", Link.None)]
147173
public RelatedModel RelatedModel { get; set; }
148174
public int RelatedModelId { get; set; }
175+
[Attr("StringProperty")]
176+
public string StringProperty { get; set; }
177+
149178
}
150179

151180
private class RelatedModel : Identifiable

0 commit comments

Comments
 (0)