Skip to content

Commit ed2b8c4

Browse files
author
Bart Koelman
committed
Resource Graph validations (#1101)
* Added extra validations of the resource graph and unified unit tests * Fail at startup on controller that derives from BaseJsonApiController, but uses a resource type that is not registered in the resource graph * Check off roadmap entry
1 parent 8413fe7 commit ed2b8c4

File tree

12 files changed

+513
-246
lines changed

12 files changed

+513
-246
lines changed

ROADMAP.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,11 @@ The need for breaking changes has blocked several efforts in the v4.x release, s
2323
- [x] Optimize IIdentifiable to ResourceObject conversion [#1028](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1028) [#1024](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1024) [#233](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/233)
2424
- [x] Nullable reference types [#1029](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1029)
2525
- [x] Improved paging links [#1010](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1010)
26+
- [x] Configuration validation [#170](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/170)
2627

2728
Aside from the list above, we have interest in the following topics. It's too soon yet to decide whether they'll make it into v5.x or in a later major version.
2829

2930
- Auto-generated controllers [#732](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/732) [#365](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/365)
30-
- Configuration validation [#170](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/170)
3131
- Optimistic concurrency [#1004](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1004)
3232
- Extract annotations into separate package [#730](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/730)
3333
- OpenAPI (Swagger) [#1046](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1046)

src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs

+83-13
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ public class ResourceGraphBuilder
2121
{
2222
private readonly IJsonApiOptions _options;
2323
private readonly ILogger<ResourceGraphBuilder> _logger;
24-
private readonly HashSet<ResourceType> _resourceTypes = new();
24+
private readonly Dictionary<Type, ResourceType> _resourceTypesByClrType = new();
2525
private readonly TypeLocator _typeLocator = new();
2626

2727
public ResourceGraphBuilder(IJsonApiOptions options, ILoggerFactory loggerFactory)
@@ -38,12 +38,27 @@ public ResourceGraphBuilder(IJsonApiOptions options, ILoggerFactory loggerFactor
3838
/// </summary>
3939
public IResourceGraph Build()
4040
{
41-
var resourceGraph = new ResourceGraph(_resourceTypes);
41+
HashSet<ResourceType> resourceTypes = _resourceTypesByClrType.Values.ToHashSet();
4242

43-
foreach (RelationshipAttribute relationship in _resourceTypes.SelectMany(resourceType => resourceType.Relationships))
43+
if (!resourceTypes.Any())
44+
{
45+
_logger.LogWarning("The resource graph is empty.");
46+
}
47+
48+
var resourceGraph = new ResourceGraph(resourceTypes);
49+
50+
foreach (RelationshipAttribute relationship in resourceTypes.SelectMany(resourceType => resourceType.Relationships))
4451
{
4552
relationship.LeftType = resourceGraph.GetResourceType(relationship.LeftClrType!);
46-
relationship.RightType = resourceGraph.GetResourceType(relationship.RightClrType!);
53+
ResourceType? rightType = resourceGraph.FindResourceType(relationship.RightClrType!);
54+
55+
if (rightType == null)
56+
{
57+
throw new InvalidConfigurationException($"Resource type '{relationship.LeftClrType}' depends on " +
58+
$"'{relationship.RightClrType}', which was not added to the resource graph.");
59+
}
60+
61+
relationship.RightType = rightType;
4762
}
4863

4964
return resourceGraph;
@@ -123,7 +138,7 @@ public ResourceGraphBuilder Add(Type resourceClrType, Type? idClrType = null, st
123138
{
124139
ArgumentGuard.NotNull(resourceClrType, nameof(resourceClrType));
125140

126-
if (_resourceTypes.Any(resourceType => resourceType.ClrType == resourceClrType))
141+
if (_resourceTypesByClrType.ContainsKey(resourceClrType))
127142
{
128143
return this;
129144
}
@@ -139,7 +154,10 @@ public ResourceGraphBuilder Add(Type resourceClrType, Type? idClrType = null, st
139154
}
140155

141156
ResourceType resourceType = CreateResourceType(effectivePublicName, resourceClrType, effectiveIdType);
142-
_resourceTypes.Add(resourceType);
157+
158+
AssertNoDuplicatePublicName(resourceType, effectivePublicName);
159+
160+
_resourceTypesByClrType.Add(resourceClrType, resourceType);
143161
}
144162
else
145163
{
@@ -155,6 +173,8 @@ private ResourceType CreateResourceType(string publicName, Type resourceClrType,
155173
IReadOnlyCollection<RelationshipAttribute> relationships = GetRelationships(resourceClrType);
156174
IReadOnlyCollection<EagerLoadAttribute> eagerLoads = GetEagerLoads(resourceClrType);
157175

176+
AssertNoDuplicatePublicName(attributes, relationships);
177+
158178
var linksAttribute = (ResourceLinksAttribute?)resourceClrType.GetCustomAttribute(typeof(ResourceLinksAttribute));
159179

160180
return linksAttribute == null
@@ -165,7 +185,7 @@ private ResourceType CreateResourceType(string publicName, Type resourceClrType,
165185

166186
private IReadOnlyCollection<AttrAttribute> GetAttributes(Type resourceClrType)
167187
{
168-
var attributes = new List<AttrAttribute>();
188+
var attributesByName = new Dictionary<string, AttrAttribute>();
169189

170190
foreach (PropertyInfo property in resourceClrType.GetProperties())
171191
{
@@ -181,7 +201,7 @@ private IReadOnlyCollection<AttrAttribute> GetAttributes(Type resourceClrType)
181201
Capabilities = _options.DefaultAttrCapabilities
182202
};
183203

184-
attributes.Add(idAttr);
204+
IncludeField(attributesByName, idAttr);
185205
continue;
186206
}
187207

@@ -200,15 +220,20 @@ private IReadOnlyCollection<AttrAttribute> GetAttributes(Type resourceClrType)
200220
attribute.Capabilities = _options.DefaultAttrCapabilities;
201221
}
202222

203-
attributes.Add(attribute);
223+
IncludeField(attributesByName, attribute);
204224
}
205225

206-
return attributes;
226+
if (attributesByName.Count < 2)
227+
{
228+
_logger.LogWarning($"Type '{resourceClrType}' does not contain any attributes.");
229+
}
230+
231+
return attributesByName.Values;
207232
}
208233

209234
private IReadOnlyCollection<RelationshipAttribute> GetRelationships(Type resourceClrType)
210235
{
211-
var relationships = new List<RelationshipAttribute>();
236+
var relationshipsByName = new Dictionary<string, RelationshipAttribute>();
212237
PropertyInfo[] properties = resourceClrType.GetProperties();
213238

214239
foreach (PropertyInfo property in properties)
@@ -222,11 +247,11 @@ private IReadOnlyCollection<RelationshipAttribute> GetRelationships(Type resourc
222247
relationship.LeftClrType = resourceClrType;
223248
relationship.RightClrType = GetRelationshipType(relationship, property);
224249

225-
relationships.Add(relationship);
250+
IncludeField(relationshipsByName, relationship);
226251
}
227252
}
228253

229-
return relationships;
254+
return relationshipsByName.Values;
230255
}
231256

232257
private void SetPublicName(ResourceFieldAttribute field, PropertyInfo property)
@@ -269,6 +294,51 @@ private IReadOnlyCollection<EagerLoadAttribute> GetEagerLoads(Type resourceClrTy
269294
return attributes;
270295
}
271296

297+
private static void IncludeField<TField>(Dictionary<string, TField> fieldsByName, TField field)
298+
where TField : ResourceFieldAttribute
299+
{
300+
if (fieldsByName.TryGetValue(field.PublicName, out var existingField))
301+
{
302+
throw CreateExceptionForDuplicatePublicName(field.Property.DeclaringType!, existingField, field);
303+
}
304+
305+
fieldsByName.Add(field.PublicName, field);
306+
}
307+
308+
private void AssertNoDuplicatePublicName(ResourceType resourceType, string effectivePublicName)
309+
{
310+
var (existingClrType, _) = _resourceTypesByClrType.FirstOrDefault(type => type.Value.PublicName == resourceType.PublicName);
311+
312+
if (existingClrType != null)
313+
{
314+
throw new InvalidConfigurationException(
315+
$"Resource '{existingClrType}' and '{resourceType.ClrType}' both use public name '{effectivePublicName}'.");
316+
}
317+
}
318+
319+
private void AssertNoDuplicatePublicName(IReadOnlyCollection<AttrAttribute> attributes, IReadOnlyCollection<RelationshipAttribute> relationships)
320+
{
321+
IEnumerable<(AttrAttribute attribute, RelationshipAttribute relationship)> query =
322+
from attribute in attributes
323+
from relationship in relationships
324+
where attribute.PublicName == relationship.PublicName
325+
select (attribute, relationship);
326+
327+
(AttrAttribute? duplicateAttribute, RelationshipAttribute? duplicateRelationship) = query.FirstOrDefault();
328+
329+
if (duplicateAttribute != null && duplicateRelationship != null)
330+
{
331+
throw CreateExceptionForDuplicatePublicName(duplicateAttribute.Property.DeclaringType!, duplicateAttribute, duplicateRelationship);
332+
}
333+
}
334+
335+
private static InvalidConfigurationException CreateExceptionForDuplicatePublicName(Type containingClrType, ResourceFieldAttribute existingField,
336+
ResourceFieldAttribute field)
337+
{
338+
return new InvalidConfigurationException(
339+
$"Properties '{containingClrType}.{existingField.Property.Name}' and '{containingClrType}.{field.Property.Name}' both use public name '{field.PublicName}'.");
340+
}
341+
272342
[AssertionMethod]
273343
private static void AssertNoInfiniteRecursion(int recursionDepth)
274344
{

src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs

+5
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,11 @@ public void Apply(ApplicationModel application)
8484
_resourceTypePerControllerTypeMap.Add(controller.ControllerType, resourceType);
8585
_controllerPerResourceTypeMap.Add(resourceType, controller);
8686
}
87+
else
88+
{
89+
throw new InvalidConfigurationException($"Controller '{controller.ControllerType}' depends on " +
90+
$"resource type '{resourceClrType}', which does not exist in the resource graph.");
91+
}
8792
}
8893
}
8994

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
using JetBrains.Annotations;
2+
using JsonApiDotNetCore.Resources;
3+
4+
namespace JsonApiDotNetCoreTests.IntegrationTests.NonJsonApiControllers
5+
{
6+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
7+
public sealed class UnknownResource : Identifiable<int>
8+
{
9+
public string? Value { get; set; }
10+
}
11+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
using System;
2+
using FluentAssertions;
3+
using JsonApiDotNetCore.Errors;
4+
using TestBuildingBlocks;
5+
using Xunit;
6+
7+
namespace JsonApiDotNetCoreTests.IntegrationTests.NonJsonApiControllers
8+
{
9+
public sealed class UnknownResourceControllerTests : IntegrationTestContext<TestableStartup<NonJsonApiDbContext>, NonJsonApiDbContext>
10+
{
11+
public UnknownResourceControllerTests()
12+
{
13+
UseController<UnknownResourcesController>();
14+
}
15+
16+
[Fact]
17+
public void Fails_at_startup_when_using_controller_for_resource_type_that_is_not_registered_in_resource_graph()
18+
{
19+
// Act
20+
Action action = () => _ = Factory;
21+
22+
// Assert
23+
action.Should().ThrowExactly<InvalidConfigurationException>().WithMessage($"Controller '{typeof(UnknownResourcesController)}' " +
24+
$"depends on resource type '{typeof(UnknownResource)}', which does not exist in the resource graph.");
25+
}
26+
27+
public override void Dispose()
28+
{
29+
// Prevents crash when test cleanup tries to access lazily constructed Factory.
30+
}
31+
}
32+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using JsonApiDotNetCore.Configuration;
2+
using JsonApiDotNetCore.Controllers;
3+
using JsonApiDotNetCore.Services;
4+
using Microsoft.Extensions.Logging;
5+
6+
namespace JsonApiDotNetCoreTests.IntegrationTests.NonJsonApiControllers
7+
{
8+
public sealed class UnknownResourcesController : JsonApiController<UnknownResource, int>
9+
{
10+
public UnknownResourcesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory,
11+
IResourceService<UnknownResource, int> resourceService)
12+
: base(options, resourceGraph, loggerFactory, resourceService)
13+
{
14+
}
15+
}
16+
}

test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj

+1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
<PackageReference Include="Macross.Json.Extensions" Version="2.0.0" />
1919
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="$(AspNetCoreVersion)" />
2020
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="$(EFCoreVersion)" />
21+
<PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="$(EFCoreVersion)" />
2122
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(TestSdkVersion)" />
2223
</ItemGroup>
2324
</Project>

0 commit comments

Comments
 (0)