Skip to content

Commit bc22ed1

Browse files
authored
Merge pull request #1593 from json-api-dotnet/fix-empty-id
Fixed: Produce error on invalid ID in request body
2 parents 28e8917 + bce1d8f commit bc22ed1

File tree

16 files changed

+1296
-85
lines changed

16 files changed

+1296
-85
lines changed

src/Examples/DapperExample/Repositories/ResultSetMapper.cs

+1-16
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,6 @@ internal sealed class ResultSetMapper<TResource, TId>
1818
// Note we don't do full bidirectional relationship fix-up; this just avoids duplicate instances.
1919
private readonly Dictionary<Type, Dictionary<object, object>> _resourceByTypeCache = [];
2020

21-
// Optimization to avoid unneeded calls to expensive Activator.CreateInstance() method, which is needed multiple times per row.
22-
private readonly Dictionary<Type, object?> _defaultValueByTypeCache = [];
23-
2421
// Used to determine where in the tree of included relationships a join object belongs to.
2522
private readonly Dictionary<IncludeElementExpression, int> _includeElementToJoinObjectArrayIndexLookup = new(ReferenceEqualityComparer.Instance);
2623

@@ -114,22 +111,10 @@ public ResultSetMapper(IncludeExpression? include)
114111

115112
private bool HasDefaultValue(object value)
116113
{
117-
object? defaultValue = GetDefaultValueCached(value.GetType());
114+
object? defaultValue = RuntimeTypeConverter.GetDefaultValue(value.GetType());
118115
return Equals(defaultValue, value);
119116
}
120117

121-
private object? GetDefaultValueCached(Type type)
122-
{
123-
if (_defaultValueByTypeCache.TryGetValue(type, out object? defaultValue))
124-
{
125-
return defaultValue;
126-
}
127-
128-
defaultValue = RuntimeTypeConverter.GetDefaultValue(type);
129-
_defaultValueByTypeCache[type] = defaultValue;
130-
return defaultValue;
131-
}
132-
133118
private void RecursiveSetRelationships(object leftResource, IEnumerable<IncludeElementExpression> includeElements, object?[] joinObjects)
134119
{
135120
foreach (IncludeElementExpression includeElement in includeElements)

src/JsonApiDotNetCore.Annotations/Resources/RuntimeTypeConverter.cs

+8-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Collections.Concurrent;
12
using System.Globalization;
23
using JetBrains.Annotations;
34

@@ -13,6 +14,8 @@ public static class RuntimeTypeConverter
1314
{
1415
private const string ParseQueryStringsUsingCurrentCultureSwitchName = "JsonApiDotNetCore.ParseQueryStringsUsingCurrentCulture";
1516

17+
private static readonly ConcurrentDictionary<Type, object?> DefaultTypeCache = new();
18+
1619
/// <summary>
1720
/// Converts the specified value to the specified type.
1821
/// </summary>
@@ -137,6 +140,8 @@ public static class RuntimeTypeConverter
137140
/// </summary>
138141
public static bool CanContainNull(Type type)
139142
{
143+
ArgumentGuard.NotNull(type);
144+
140145
return !type.IsValueType || Nullable.GetUnderlyingType(type) != null;
141146
}
142147

@@ -148,6 +153,8 @@ public static bool CanContainNull(Type type)
148153
/// </returns>
149154
public static object? GetDefaultValue(Type type)
150155
{
151-
return type.IsValueType ? Activator.CreateInstance(type) : null;
156+
ArgumentGuard.NotNull(type);
157+
158+
return type.IsValueType ? DefaultTypeCache.GetOrAdd(type, Activator.CreateInstance) : null;
152159
}
153160
}

src/JsonApiDotNetCore/Controllers/JsonApiController.cs

+14-12
Original file line numberDiff line numberDiff line change
@@ -51,15 +51,15 @@ public override async Task<IActionResult> GetAsync(CancellationToken cancellatio
5151
/// <inheritdoc />
5252
[HttpGet("{id}")]
5353
[HttpHead("{id}")]
54-
public override async Task<IActionResult> GetAsync([Required] [DisallowNull] TId id, CancellationToken cancellationToken)
54+
public override async Task<IActionResult> GetAsync([Required(AllowEmptyStrings = true)] [DisallowNull] TId id, CancellationToken cancellationToken)
5555
{
5656
return await base.GetAsync(id, cancellationToken);
5757
}
5858

5959
/// <inheritdoc />
6060
[HttpGet("{id}/{relationshipName}")]
6161
[HttpHead("{id}/{relationshipName}")]
62-
public override async Task<IActionResult> GetSecondaryAsync([Required] [DisallowNull] TId id, [Required] string relationshipName,
62+
public override async Task<IActionResult> GetSecondaryAsync([Required(AllowEmptyStrings = true)] [DisallowNull] TId id, [Required] string relationshipName,
6363
CancellationToken cancellationToken)
6464
{
6565
return await base.GetSecondaryAsync(id, relationshipName, cancellationToken);
@@ -68,8 +68,8 @@ public override async Task<IActionResult> GetSecondaryAsync([Required] [Disallow
6868
/// <inheritdoc />
6969
[HttpGet("{id}/relationships/{relationshipName}")]
7070
[HttpHead("{id}/relationships/{relationshipName}")]
71-
public override async Task<IActionResult> GetRelationshipAsync([Required] [DisallowNull] TId id, [Required] string relationshipName,
72-
CancellationToken cancellationToken)
71+
public override async Task<IActionResult> GetRelationshipAsync([Required(AllowEmptyStrings = true)] [DisallowNull] TId id,
72+
[Required] string relationshipName, CancellationToken cancellationToken)
7373
{
7474
return await base.GetRelationshipAsync(id, relationshipName, cancellationToken);
7575
}
@@ -83,39 +83,41 @@ public override async Task<IActionResult> PostAsync([Required] TResource resourc
8383

8484
/// <inheritdoc />
8585
[HttpPost("{id}/relationships/{relationshipName}")]
86-
public override async Task<IActionResult> PostRelationshipAsync([Required] [DisallowNull] TId id, [Required] string relationshipName,
87-
[Required] ISet<IIdentifiable> rightResourceIds, CancellationToken cancellationToken)
86+
public override async Task<IActionResult> PostRelationshipAsync([Required(AllowEmptyStrings = true)] [DisallowNull] TId id,
87+
[Required] string relationshipName, [Required] ISet<IIdentifiable> rightResourceIds, CancellationToken cancellationToken)
8888
{
8989
return await base.PostRelationshipAsync(id, relationshipName, rightResourceIds, cancellationToken);
9090
}
9191

9292
/// <inheritdoc />
9393
[HttpPatch("{id}")]
94-
public override async Task<IActionResult> PatchAsync([Required] [DisallowNull] TId id, [Required] TResource resource, CancellationToken cancellationToken)
94+
public override async Task<IActionResult> PatchAsync([Required(AllowEmptyStrings = true)] [DisallowNull] TId id, [Required] TResource resource,
95+
CancellationToken cancellationToken)
9596
{
9697
return await base.PatchAsync(id, resource, cancellationToken);
9798
}
9899

99100
/// <inheritdoc />
100101
[HttpPatch("{id}/relationships/{relationshipName}")]
102+
// `AllowEmptyStrings = true` in `[Required]` prevents the model binder from producing a validation error on whitespace when TId is string.
101103
// Parameter `[Required] object? rightValue` makes Swashbuckle generate the OpenAPI request body as required. We don't actually validate ModelState, so it doesn't hurt.
102-
public override async Task<IActionResult> PatchRelationshipAsync([Required] [DisallowNull] TId id, [Required] string relationshipName,
103-
[Required] object? rightValue, CancellationToken cancellationToken)
104+
public override async Task<IActionResult> PatchRelationshipAsync([Required(AllowEmptyStrings = true)] [DisallowNull] TId id,
105+
[Required] string relationshipName, [Required] object? rightValue, CancellationToken cancellationToken)
104106
{
105107
return await base.PatchRelationshipAsync(id, relationshipName, rightValue, cancellationToken);
106108
}
107109

108110
/// <inheritdoc />
109111
[HttpDelete("{id}")]
110-
public override async Task<IActionResult> DeleteAsync([Required] [DisallowNull] TId id, CancellationToken cancellationToken)
112+
public override async Task<IActionResult> DeleteAsync([Required(AllowEmptyStrings = true)] [DisallowNull] TId id, CancellationToken cancellationToken)
111113
{
112114
return await base.DeleteAsync(id, cancellationToken);
113115
}
114116

115117
/// <inheritdoc />
116118
[HttpDelete("{id}/relationships/{relationshipName}")]
117-
public override async Task<IActionResult> DeleteRelationshipAsync([Required] [DisallowNull] TId id, [Required] string relationshipName,
118-
[Required] ISet<IIdentifiable> rightResourceIds, CancellationToken cancellationToken)
119+
public override async Task<IActionResult> DeleteRelationshipAsync([Required(AllowEmptyStrings = true)] [DisallowNull] TId id,
120+
[Required] string relationshipName, [Required] ISet<IIdentifiable> rightResourceIds, CancellationToken cancellationToken)
119121
{
120122
return await base.DeleteRelationshipAsync(id, relationshipName, rightResourceIds, cancellationToken);
121123
}

src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs

+4-9
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,12 @@ public static object GetTypedId(this IIdentifiable identifiable)
1818
}
1919

2020
object? propertyValue = property.GetValue(identifiable);
21+
object? defaultValue = RuntimeTypeConverter.GetDefaultValue(property.PropertyType);
2122

22-
// PERF: We want to throw when 'Id' is unassigned without doing an expensive reflection call, unless this is likely the case.
23-
if (identifiable.StringId == null)
23+
if (Equals(propertyValue, defaultValue))
2424
{
25-
object? defaultValue = RuntimeTypeConverter.GetDefaultValue(property.PropertyType);
26-
27-
if (Equals(propertyValue, defaultValue))
28-
{
29-
throw new InvalidOperationException($"Property '{identifiable.GetClrType().Name}.{IdPropertyName}' should " +
30-
$"have been assigned at this point, but it contains its default {property.PropertyType.Name} value '{propertyValue}'.");
31-
}
25+
throw new InvalidOperationException($"Property '{identifiable.GetClrType().Name}.{IdPropertyName}' should " +
26+
$"have been assigned at this point, but it contains its default {property.PropertyType.Name} value '{propertyValue}'.");
3227
}
3328

3429
return propertyValue!;

src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs

+20
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ private IIdentifiable CreateResource(ResourceIdentity identity, ResourceIdentity
116116
AssertHasNoId(identity, state);
117117
}
118118

119+
AssertNoBrokenId(identity, resourceType.IdentityClrType, state);
119120
AssertSameIdValue(identity, requirements.IdValue, state);
120121
AssertSameLidValue(identity, requirements.LidValue, state);
121122

@@ -177,6 +178,25 @@ private static void AssertHasNoId(ResourceIdentity identity, RequestAdapterState
177178
}
178179
}
179180

181+
private static void AssertNoBrokenId(ResourceIdentity identity, Type resourceIdClrType, RequestAdapterState state)
182+
{
183+
if (identity.Id != null)
184+
{
185+
if (resourceIdClrType == typeof(string))
186+
{
187+
// Empty and whitespace strings are valid when TId is string.
188+
return;
189+
}
190+
191+
string? defaultIdValue = RuntimeTypeConverter.GetDefaultValue(resourceIdClrType)?.ToString();
192+
193+
if (string.IsNullOrWhiteSpace(identity.Id) || identity.Id == defaultIdValue)
194+
{
195+
throw new ModelConversionException(state.Position, "The 'id' element is invalid.", null);
196+
}
197+
}
198+
}
199+
180200
private static void AssertSameIdValue(ResourceIdentity identity, string? expected, RequestAdapterState state)
181201
{
182202
if (expected != null && identity.Id != expected)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
using JetBrains.Annotations;
2+
using JsonApiDotNetCore.Configuration;
3+
using JsonApiDotNetCore.Middleware;
4+
5+
namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Creating;
6+
7+
[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)]
8+
public sealed class AssignIdToTextLanguageDefinition(IResourceGraph resourceGraph, ResourceDefinitionHitCounter hitCounter, OperationsDbContext dbContext)
9+
: ImplicitlyChangingTextLanguageDefinition(resourceGraph, hitCounter, dbContext)
10+
{
11+
public override Task OnWritingAsync(TextLanguage resource, WriteOperationKind writeOperation, CancellationToken cancellationToken)
12+
{
13+
if (writeOperation == WriteOperationKind.CreateResource && resource.Id == Guid.Empty)
14+
{
15+
resource.Id = Guid.NewGuid();
16+
}
17+
18+
return Task.CompletedTask;
19+
}
20+
}

0 commit comments

Comments
 (0)