Skip to content

Commit f5dbd23

Browse files
committed
Add tests for non-primitive id
1 parent 5f85f93 commit f5dbd23

File tree

10 files changed

+492
-0
lines changed

10 files changed

+492
-0
lines changed
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
using System.Buffers;
2+
using System.Buffers.Text;
3+
using System.Diagnostics;
4+
using System.Diagnostics.CodeAnalysis;
5+
using System.Text;
6+
using System.Text.Json;
7+
using System.Text.Json.Serialization;
8+
9+
namespace JsonApiDotNetCoreTests.IntegrationTests.IdCompaction;
10+
11+
[JsonConverter(typeof(IdJsonConverter))]
12+
public readonly struct CompactGuid(Guid value) :
13+
IComparable,
14+
IComparable<CompactGuid>,
15+
IEquatable<CompactGuid>,
16+
ISpanParsable<CompactGuid>,
17+
IUtf8SpanParsable<CompactGuid>,
18+
ISpanFormattable,
19+
IUtf8SpanFormattable
20+
{
21+
private const int GuidByteSize = 16;
22+
private const int IdCharMaxSize = 24;
23+
24+
public static readonly CompactGuid Empty = new(Guid.Empty);
25+
26+
public static CompactGuid Create()
27+
{
28+
return new CompactGuid(Guid.NewGuid());
29+
}
30+
31+
/// <inheritdoc />
32+
public static CompactGuid Parse(string s, IFormatProvider? provider = null)
33+
{
34+
ArgumentNullException.ThrowIfNull(s);
35+
return Parse(s.AsSpan(), provider);
36+
}
37+
38+
/// <inheritdoc />
39+
public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, out CompactGuid result)
40+
{
41+
return TryParse(s.AsSpan(), provider, out result);
42+
}
43+
44+
/// <inheritdoc />
45+
public static CompactGuid Parse(ReadOnlySpan<char> s, IFormatProvider? provider = null)
46+
{
47+
if (!TryParse(s, provider, out var result))
48+
{
49+
throw new ArgumentException(null, nameof(s));
50+
}
51+
52+
return result;
53+
}
54+
55+
/// <inheritdoc />
56+
public static bool TryParse(ReadOnlySpan<char> s, IFormatProvider? provider, out CompactGuid result)
57+
{
58+
Span<byte> charBytes = stackalloc byte[IdCharMaxSize];
59+
if (!Encoding.ASCII.TryGetBytes(s, charBytes, out var charBytesLength))
60+
{
61+
result = default;
62+
return false;
63+
}
64+
65+
return TryParse(charBytes[..charBytesLength], provider, out result);
66+
}
67+
68+
/// <inheritdoc />
69+
public static CompactGuid Parse(ReadOnlySpan<byte> utf8Text, IFormatProvider? provider = null)
70+
{
71+
if (!TryParse(utf8Text, provider, out var result))
72+
{
73+
throw new ArgumentException(null, nameof(utf8Text));
74+
}
75+
76+
return result;
77+
}
78+
79+
/// <inheritdoc />
80+
public static bool TryParse(ReadOnlySpan<byte> utf8Text, IFormatProvider? provider, out CompactGuid result)
81+
{
82+
Span<byte> valueBytes = stackalloc byte[GuidByteSize];
83+
OperationStatus status = Base64.DecodeFromUtf8(utf8Text, valueBytes, out _, out int written);
84+
85+
if (status != OperationStatus.Done || written < GuidByteSize)
86+
{
87+
result = default;
88+
return false;
89+
}
90+
91+
Guid value = new(valueBytes);
92+
result = new CompactGuid(value);
93+
return true;
94+
}
95+
96+
public static explicit operator Guid(CompactGuid id)
97+
{
98+
return id._value;
99+
}
100+
101+
public static bool operator ==(CompactGuid id1, CompactGuid id2)
102+
{
103+
return id1.Equals(id2);
104+
}
105+
106+
public static bool operator !=(CompactGuid id1, CompactGuid id2)
107+
{
108+
return !id1.Equals(id2);
109+
}
110+
111+
public static bool operator <(CompactGuid id1, CompactGuid id2)
112+
{
113+
return id1._value < id2._value;
114+
}
115+
116+
public static bool operator >(CompactGuid id1, CompactGuid id2)
117+
{
118+
return id1._value > id2._value;
119+
}
120+
121+
public static bool operator <=(CompactGuid id1, CompactGuid id2)
122+
{
123+
return id1.CompareTo(id2) <= 0;
124+
}
125+
126+
public static bool operator >=(CompactGuid id1, CompactGuid id2)
127+
{
128+
return id1.CompareTo(id2) >= 0;
129+
}
130+
131+
private readonly Guid _value = value;
132+
133+
/// <inheritdoc />
134+
public override bool Equals([NotNullWhen(true)] object? obj)
135+
{
136+
return base.Equals(obj);
137+
}
138+
139+
/// <inheritdoc />
140+
public bool Equals(CompactGuid other)
141+
{
142+
return _value.Equals(other._value);
143+
}
144+
145+
/// <inheritdoc />
146+
public int CompareTo(object? obj)
147+
{
148+
if (obj == null)
149+
{
150+
return 1;
151+
}
152+
153+
if (obj is CompactGuid other)
154+
{
155+
return CompareTo(other);
156+
}
157+
158+
throw new ArgumentException(null, nameof(obj));
159+
}
160+
161+
/// <inheritdoc />
162+
public int CompareTo(CompactGuid other)
163+
{
164+
return _value.CompareTo(other._value);
165+
}
166+
167+
/// <inheritdoc />
168+
public override int GetHashCode()
169+
{
170+
return _value.GetHashCode();
171+
}
172+
173+
/// <inheritdoc />
174+
public override string ToString()
175+
{
176+
Span<byte> valueBytes = stackalloc byte[GuidByteSize];
177+
bool ok = _value.TryWriteBytes(valueBytes);
178+
Debug.Assert(ok);
179+
180+
Span<byte> charBytes = stackalloc byte[IdCharMaxSize];
181+
OperationStatus status = Base64.EncodeToUtf8(valueBytes, charBytes, out int consumed, out int written);
182+
Debug.Assert(status == OperationStatus.Done && consumed == GuidByteSize && written <= IdCharMaxSize);
183+
184+
return Encoding.ASCII.GetString(charBytes[..written]);
185+
}
186+
187+
/// <inheritdoc />
188+
public string ToString(string? format, IFormatProvider? formatProvider = null)
189+
{
190+
return ToString();
191+
}
192+
193+
/// <inheritdoc />
194+
public bool TryFormat(Span<char> destination, out int charsWritten, ReadOnlySpan<char> format, IFormatProvider? provider = null)
195+
{
196+
Span<byte> charBytes = stackalloc byte[IdCharMaxSize];
197+
if (!TryFormat(charBytes, out int bytesWritten, format, provider))
198+
{
199+
charsWritten = 0;
200+
return false;
201+
}
202+
203+
Debug.Assert(bytesWritten <= IdCharMaxSize);
204+
205+
charsWritten = Encoding.ASCII.GetChars(charBytes[..bytesWritten], destination);
206+
Debug.Assert(charsWritten == bytesWritten);
207+
return true;
208+
}
209+
210+
/// <inheritdoc />
211+
public bool TryFormat(Span<byte> utf8Destination, out int bytesWritten, ReadOnlySpan<char> format, IFormatProvider? provider = null)
212+
{
213+
Span<byte> valueBytes = stackalloc byte[GuidByteSize];
214+
if (!_value.TryWriteBytes(valueBytes))
215+
{
216+
bytesWritten = 0;
217+
return false;
218+
}
219+
220+
OperationStatus status = Base64.EncodeToUtf8(valueBytes, utf8Destination, out int consumed, out bytesWritten);
221+
Debug.Assert(status == OperationStatus.Done && consumed == GuidByteSize && bytesWritten <= IdCharMaxSize);
222+
223+
return true;
224+
}
225+
226+
private sealed class IdJsonConverter : JsonConverter<CompactGuid>
227+
{
228+
public override CompactGuid Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
229+
{
230+
if (reader.TokenType != JsonTokenType.String)
231+
{
232+
throw new ArgumentException("String expected");
233+
}
234+
235+
if (reader.HasValueSequence)
236+
{
237+
var seq = reader.ValueSequence;
238+
return Parse(seq.IsSingleSegment ? seq.FirstSpan : seq.ToArray());
239+
}
240+
241+
return Parse(reader.ValueSpan);
242+
}
243+
244+
public override void Write(Utf8JsonWriter writer, CompactGuid value, JsonSerializerOptions options)
245+
{
246+
Span<byte> idBytes = stackalloc byte[IdCharMaxSize];
247+
_ = value.TryFormat(idBytes, out _, ReadOnlySpan<char>.Empty);
248+
writer.WriteStringValue(idBytes);
249+
}
250+
}
251+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
2+
3+
namespace JsonApiDotNetCoreTests.IntegrationTests.IdCompaction;
4+
5+
public class CompactGuidConverter() : ValueConverter<CompactGuid, Guid>(
6+
id => (Guid)id,
7+
value => new CompactGuid(value));
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
using JsonApiDotNetCore.Resources;
2+
3+
namespace JsonApiDotNetCoreTests.IntegrationTests.IdCompaction;
4+
5+
// Tip: Add [HideResourceIdTypeInOpenApi] if you're using OpenAPI with JsonApiDotNetCore.OpenApi.Swashbuckle.
6+
public abstract class CompactIdentifiable : Identifiable<CompactGuid>
7+
{
8+
protected override string? GetStringId(CompactGuid value)
9+
{
10+
return value == CompactGuid.Empty ? null : value.ToString();
11+
}
12+
13+
protected override CompactGuid GetTypedId(string? value)
14+
{
15+
return value == null ? CompactGuid.Empty : CompactGuid.Parse(value);
16+
}
17+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
using System.ComponentModel.DataAnnotations;
2+
using JsonApiDotNetCore.Configuration;
3+
using JsonApiDotNetCore.Controllers;
4+
using JsonApiDotNetCore.Resources;
5+
using JsonApiDotNetCore.Services;
6+
using Microsoft.AspNetCore.Mvc;
7+
using Microsoft.Extensions.Logging;
8+
9+
#pragma warning disable format
10+
11+
namespace JsonApiDotNetCoreTests.IntegrationTests.IdCompaction;
12+
13+
public abstract class CompactIdentifiableController<TResource>(
14+
IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IResourceService<TResource, CompactGuid> resourceService)
15+
: BaseJsonApiController<TResource, CompactGuid>(options, resourceGraph, loggerFactory, resourceService)
16+
where TResource : class, IIdentifiable<CompactGuid>
17+
{
18+
[HttpGet]
19+
[HttpHead]
20+
public override Task<IActionResult> GetAsync(CancellationToken cancellationToken)
21+
{
22+
return base.GetAsync(cancellationToken);
23+
}
24+
25+
[HttpGet("{id}")]
26+
[HttpHead("{id}")]
27+
public Task<IActionResult> GetAsync([Required] string id, CancellationToken cancellationToken)
28+
{
29+
CompactGuid idValue = CompactGuid.Parse(id);
30+
return base.GetAsync(idValue, cancellationToken);
31+
}
32+
33+
[HttpGet("{id}/{relationshipName}")]
34+
[HttpHead("{id}/{relationshipName}")]
35+
public Task<IActionResult> GetSecondaryAsync([Required] string id, [Required] [PreserveEmptyString] string relationshipName,
36+
CancellationToken cancellationToken)
37+
{
38+
CompactGuid idValue = CompactGuid.Parse(id);
39+
return base.GetSecondaryAsync(idValue, relationshipName, cancellationToken);
40+
}
41+
42+
[HttpGet("{id}/relationships/{relationshipName}")]
43+
[HttpHead("{id}/relationships/{relationshipName}")]
44+
public Task<IActionResult> GetRelationshipAsync([Required] string id, [Required] [PreserveEmptyString] string relationshipName,
45+
CancellationToken cancellationToken)
46+
{
47+
CompactGuid idValue = CompactGuid.Parse(id);
48+
return base.GetRelationshipAsync(idValue, relationshipName, cancellationToken);
49+
}
50+
51+
[HttpPost]
52+
public override Task<IActionResult> PostAsync([FromBody] [Required] TResource resource, CancellationToken cancellationToken)
53+
{
54+
return base.PostAsync(resource, cancellationToken);
55+
}
56+
57+
[HttpPost("{id}/relationships/{relationshipName}")]
58+
public Task<IActionResult> PostRelationshipAsync([Required] string id, [Required] [PreserveEmptyString] string relationshipName,
59+
[FromBody] [Required] ISet<IIdentifiable> rightResourceIds, CancellationToken cancellationToken)
60+
{
61+
CompactGuid idValue = CompactGuid.Parse(id);
62+
return base.PostRelationshipAsync(idValue, relationshipName, rightResourceIds, cancellationToken);
63+
}
64+
65+
[HttpPatch("{id}")]
66+
public Task<IActionResult> PatchAsync([Required] string id, [FromBody] [Required] TResource resource, CancellationToken cancellationToken)
67+
{
68+
CompactGuid idValue = CompactGuid.Parse(id);
69+
return base.PatchAsync(idValue, resource, cancellationToken);
70+
}
71+
72+
[HttpPatch("{id}/relationships/{relationshipName}")]
73+
// Parameter `[Required] object? rightValue` makes Swashbuckle generate the OpenAPI request body as required. We don't actually validate ModelState, so it doesn't hurt.
74+
public Task<IActionResult> PatchRelationshipAsync([Required] string id, [Required] [PreserveEmptyString] string relationshipName,
75+
[FromBody] [Required] object? rightValue, CancellationToken cancellationToken)
76+
{
77+
CompactGuid idValue = CompactGuid.Parse(id);
78+
return base.PatchRelationshipAsync(idValue, relationshipName, rightValue, cancellationToken);
79+
}
80+
81+
[HttpDelete("{id}")]
82+
public Task<IActionResult> DeleteAsync([Required] string id, CancellationToken cancellationToken)
83+
{
84+
CompactGuid idValue = CompactGuid.Parse(id);
85+
return base.DeleteAsync(idValue, cancellationToken);
86+
}
87+
88+
[HttpDelete("{id}/relationships/{relationshipName}")]
89+
public Task<IActionResult> DeleteRelationshipAsync([Required] string id, [Required] [PreserveEmptyString] string relationshipName,
90+
[FromBody] [Required] ISet<IIdentifiable> rightResourceIds, CancellationToken cancellationToken)
91+
{
92+
CompactGuid idValue = CompactGuid.Parse(id);
93+
return base.DeleteRelationshipAsync(idValue, relationshipName, rightResourceIds, cancellationToken);
94+
}
95+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
using JetBrains.Annotations;
2+
using JsonApiDotNetCore.Controllers;
3+
using JsonApiDotNetCore.Resources.Annotations;
4+
5+
namespace JsonApiDotNetCoreTests.IntegrationTests.IdCompaction;
6+
7+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
8+
[Resource(GenerateControllerEndpoints = JsonApiEndpoints.None)]
9+
public sealed class Grant : CompactIdentifiable
10+
{
11+
[Attr]
12+
public string Name { get; set; } = null!;
13+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
using JsonApiDotNetCore.Configuration;
2+
using JsonApiDotNetCore.Services;
3+
using Microsoft.Extensions.Logging;
4+
5+
namespace JsonApiDotNetCoreTests.IntegrationTests.IdCompaction;
6+
7+
public sealed class GrantsController(
8+
IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IResourceService<Grant, CompactGuid> resourceService)
9+
: CompactIdentifiableController<Grant>(options, resourceGraph, loggerFactory, resourceService);

0 commit comments

Comments
 (0)