diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdCompaction/CompactGuid.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdCompaction/CompactGuid.cs new file mode 100644 index 0000000000..bef40dc4bd --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdCompaction/CompactGuid.cs @@ -0,0 +1,251 @@ +using System.Buffers; +using System.Buffers.Text; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace JsonApiDotNetCoreTests.IntegrationTests.IdCompaction; + +[JsonConverter(typeof(IdJsonConverter))] +public readonly struct CompactGuid(Guid value) : + IComparable, + IComparable, + IEquatable, + ISpanParsable, + IUtf8SpanParsable, + ISpanFormattable, + IUtf8SpanFormattable +{ + private const int GuidByteSize = 16; + private const int IdCharMaxSize = 24; + + public static readonly CompactGuid Empty = new(Guid.Empty); + + public static CompactGuid Create() + { + return new CompactGuid(Guid.NewGuid()); + } + + /// + public static CompactGuid Parse(string s, IFormatProvider? provider = null) + { + ArgumentNullException.ThrowIfNull(s); + return Parse(s.AsSpan(), provider); + } + + /// + public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, out CompactGuid result) + { + return TryParse(s.AsSpan(), provider, out result); + } + + /// + public static CompactGuid Parse(ReadOnlySpan s, IFormatProvider? provider = null) + { + if (!TryParse(s, provider, out var result)) + { + throw new ArgumentException(null, nameof(s)); + } + + return result; + } + + /// + public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, out CompactGuid result) + { + Span charBytes = stackalloc byte[IdCharMaxSize]; + if (!Encoding.ASCII.TryGetBytes(s, charBytes, out var charBytesLength)) + { + result = default; + return false; + } + + return TryParse(charBytes[..charBytesLength], provider, out result); + } + + /// + public static CompactGuid Parse(ReadOnlySpan utf8Text, IFormatProvider? provider = null) + { + if (!TryParse(utf8Text, provider, out var result)) + { + throw new ArgumentException(null, nameof(utf8Text)); + } + + return result; + } + + /// + public static bool TryParse(ReadOnlySpan utf8Text, IFormatProvider? provider, out CompactGuid result) + { + Span valueBytes = stackalloc byte[GuidByteSize]; + OperationStatus status = Base64.DecodeFromUtf8(utf8Text, valueBytes, out _, out int written); + + if (status != OperationStatus.Done || written < GuidByteSize) + { + result = default; + return false; + } + + Guid value = new(valueBytes); + result = new CompactGuid(value); + return true; + } + + public static explicit operator Guid(CompactGuid id) + { + return id._value; + } + + public static bool operator ==(CompactGuid id1, CompactGuid id2) + { + return id1.Equals(id2); + } + + public static bool operator !=(CompactGuid id1, CompactGuid id2) + { + return !id1.Equals(id2); + } + + public static bool operator <(CompactGuid id1, CompactGuid id2) + { + return id1._value < id2._value; + } + + public static bool operator >(CompactGuid id1, CompactGuid id2) + { + return id1._value > id2._value; + } + + public static bool operator <=(CompactGuid id1, CompactGuid id2) + { + return id1.CompareTo(id2) <= 0; + } + + public static bool operator >=(CompactGuid id1, CompactGuid id2) + { + return id1.CompareTo(id2) >= 0; + } + + private readonly Guid _value = value; + + /// + public override bool Equals([NotNullWhen(true)] object? obj) + { + return base.Equals(obj); + } + + /// + public bool Equals(CompactGuid other) + { + return _value.Equals(other._value); + } + + /// + public int CompareTo(object? obj) + { + if (obj == null) + { + return 1; + } + + if (obj is CompactGuid other) + { + return CompareTo(other); + } + + throw new ArgumentException(null, nameof(obj)); + } + + /// + public int CompareTo(CompactGuid other) + { + return _value.CompareTo(other._value); + } + + /// + public override int GetHashCode() + { + return _value.GetHashCode(); + } + + /// + public override string ToString() + { + Span valueBytes = stackalloc byte[GuidByteSize]; + bool ok = _value.TryWriteBytes(valueBytes); + Debug.Assert(ok); + + Span charBytes = stackalloc byte[IdCharMaxSize]; + OperationStatus status = Base64.EncodeToUtf8(valueBytes, charBytes, out int consumed, out int written); + Debug.Assert(status == OperationStatus.Done && consumed == GuidByteSize && written <= IdCharMaxSize); + + return Encoding.ASCII.GetString(charBytes[..written]); + } + + /// + public string ToString(string? format, IFormatProvider? formatProvider = null) + { + return ToString(); + } + + /// + public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider = null) + { + Span charBytes = stackalloc byte[IdCharMaxSize]; + if (!TryFormat(charBytes, out int bytesWritten, format, provider)) + { + charsWritten = 0; + return false; + } + + Debug.Assert(bytesWritten <= IdCharMaxSize); + + charsWritten = Encoding.ASCII.GetChars(charBytes[..bytesWritten], destination); + Debug.Assert(charsWritten == bytesWritten); + return true; + } + + /// + public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnlySpan format, IFormatProvider? provider = null) + { + Span valueBytes = stackalloc byte[GuidByteSize]; + if (!_value.TryWriteBytes(valueBytes)) + { + bytesWritten = 0; + return false; + } + + OperationStatus status = Base64.EncodeToUtf8(valueBytes, utf8Destination, out int consumed, out bytesWritten); + Debug.Assert(status == OperationStatus.Done && consumed == GuidByteSize && bytesWritten <= IdCharMaxSize); + + return true; + } + + private sealed class IdJsonConverter : JsonConverter + { + public override CompactGuid Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + { + throw new ArgumentException("String expected"); + } + + if (reader.HasValueSequence) + { + var seq = reader.ValueSequence; + return Parse(seq.IsSingleSegment ? seq.FirstSpan : seq.ToArray()); + } + + return Parse(reader.ValueSpan); + } + + public override void Write(Utf8JsonWriter writer, CompactGuid value, JsonSerializerOptions options) + { + Span idBytes = stackalloc byte[IdCharMaxSize]; + _ = value.TryFormat(idBytes, out _, ReadOnlySpan.Empty); + writer.WriteStringValue(idBytes); + } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdCompaction/CompactGuidConverter.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdCompaction/CompactGuidConverter.cs new file mode 100644 index 0000000000..6b1c6b39ee --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdCompaction/CompactGuidConverter.cs @@ -0,0 +1,7 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace JsonApiDotNetCoreTests.IntegrationTests.IdCompaction; + +public class CompactGuidConverter() : ValueConverter( + id => (Guid)id, + value => new CompactGuid(value)); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdCompaction/CompactIdentifiable.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdCompaction/CompactIdentifiable.cs new file mode 100644 index 0000000000..fc651bebb6 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdCompaction/CompactIdentifiable.cs @@ -0,0 +1,17 @@ +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCoreTests.IntegrationTests.IdCompaction; + +// Tip: Add [HideResourceIdTypeInOpenApi] if you're using OpenAPI with JsonApiDotNetCore.OpenApi.Swashbuckle. +public abstract class CompactIdentifiable : Identifiable +{ + protected override string? GetStringId(CompactGuid value) + { + return value == CompactGuid.Empty ? null : value.ToString(); + } + + protected override CompactGuid GetTypedId(string? value) + { + return value == null ? CompactGuid.Empty : CompactGuid.Parse(value); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdCompaction/CompactIdentifiableController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdCompaction/CompactIdentifiableController.cs new file mode 100644 index 0000000000..235ff5bf15 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdCompaction/CompactIdentifiableController.cs @@ -0,0 +1,95 @@ +using System.ComponentModel.DataAnnotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +#pragma warning disable format + +namespace JsonApiDotNetCoreTests.IntegrationTests.IdCompaction; + +public abstract class CompactIdentifiableController( + IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IResourceService resourceService) + : BaseJsonApiController(options, resourceGraph, loggerFactory, resourceService) + where TResource : class, IIdentifiable +{ + [HttpGet] + [HttpHead] + public override Task GetAsync(CancellationToken cancellationToken) + { + return base.GetAsync(cancellationToken); + } + + [HttpGet("{id}")] + [HttpHead("{id}")] + public Task GetAsync([Required] string id, CancellationToken cancellationToken) + { + CompactGuid idValue = CompactGuid.Parse(id); + return base.GetAsync(idValue, cancellationToken); + } + + [HttpGet("{id}/{relationshipName}")] + [HttpHead("{id}/{relationshipName}")] + public Task GetSecondaryAsync([Required] string id, [Required] [PreserveEmptyString] string relationshipName, + CancellationToken cancellationToken) + { + CompactGuid idValue = CompactGuid.Parse(id); + return base.GetSecondaryAsync(idValue, relationshipName, cancellationToken); + } + + [HttpGet("{id}/relationships/{relationshipName}")] + [HttpHead("{id}/relationships/{relationshipName}")] + public Task GetRelationshipAsync([Required] string id, [Required] [PreserveEmptyString] string relationshipName, + CancellationToken cancellationToken) + { + CompactGuid idValue = CompactGuid.Parse(id); + return base.GetRelationshipAsync(idValue, relationshipName, cancellationToken); + } + + [HttpPost] + public override Task PostAsync([FromBody] [Required] TResource resource, CancellationToken cancellationToken) + { + return base.PostAsync(resource, cancellationToken); + } + + [HttpPost("{id}/relationships/{relationshipName}")] + public Task PostRelationshipAsync([Required] string id, [Required] [PreserveEmptyString] string relationshipName, + [FromBody] [Required] ISet rightResourceIds, CancellationToken cancellationToken) + { + CompactGuid idValue = CompactGuid.Parse(id); + return base.PostRelationshipAsync(idValue, relationshipName, rightResourceIds, cancellationToken); + } + + [HttpPatch("{id}")] + public Task PatchAsync([Required] string id, [FromBody] [Required] TResource resource, CancellationToken cancellationToken) + { + CompactGuid idValue = CompactGuid.Parse(id); + return base.PatchAsync(idValue, resource, cancellationToken); + } + + [HttpPatch("{id}/relationships/{relationshipName}")] + // Parameter `[Required] object? rightValue` makes Swashbuckle generate the OpenAPI request body as required. We don't actually validate ModelState, so it doesn't hurt. + public Task PatchRelationshipAsync([Required] string id, [Required] [PreserveEmptyString] string relationshipName, + [FromBody] [Required] object? rightValue, CancellationToken cancellationToken) + { + CompactGuid idValue = CompactGuid.Parse(id); + return base.PatchRelationshipAsync(idValue, relationshipName, rightValue, cancellationToken); + } + + [HttpDelete("{id}")] + public Task DeleteAsync([Required] string id, CancellationToken cancellationToken) + { + CompactGuid idValue = CompactGuid.Parse(id); + return base.DeleteAsync(idValue, cancellationToken); + } + + [HttpDelete("{id}/relationships/{relationshipName}")] + public Task DeleteRelationshipAsync([Required] string id, [Required] [PreserveEmptyString] string relationshipName, + [FromBody] [Required] ISet rightResourceIds, CancellationToken cancellationToken) + { + CompactGuid idValue = CompactGuid.Parse(id); + return base.DeleteRelationshipAsync(idValue, relationshipName, rightResourceIds, cancellationToken); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdCompaction/Grant.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdCompaction/Grant.cs new file mode 100644 index 0000000000..3e7592ad37 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdCompaction/Grant.cs @@ -0,0 +1,13 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.IdCompaction; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(GenerateControllerEndpoints = JsonApiEndpoints.None)] +public sealed class Grant : CompactIdentifiable +{ + [Attr] + public string Name { get; set; } = null!; +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdCompaction/GrantsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdCompaction/GrantsController.cs new file mode 100644 index 0000000000..dde0181493 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdCompaction/GrantsController.cs @@ -0,0 +1,9 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreTests.IntegrationTests.IdCompaction; + +public sealed class GrantsController( + IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IResourceService resourceService) + : CompactIdentifiableController(options, resourceGraph, loggerFactory, resourceService); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdCompaction/IdCompactionDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdCompaction/IdCompactionDbContext.cs new file mode 100644 index 0000000000..8476808ef8 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdCompaction/IdCompactionDbContext.cs @@ -0,0 +1,19 @@ +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; + +namespace JsonApiDotNetCoreTests.IntegrationTests.IdCompaction; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class IdCompactionDbContext(DbContextOptions options) + : TestableDbContext(options) +{ + public DbSet Grants => Set(); + + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + configurationBuilder + .Properties() + .HaveConversion(); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdCompaction/IdCompactionStartup.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdCompaction/IdCompactionStartup.cs new file mode 100644 index 0000000000..01898034cc --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdCompaction/IdCompactionStartup.cs @@ -0,0 +1,13 @@ +using JsonApiDotNetCore.Configuration; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; + +namespace JsonApiDotNetCoreTests.IntegrationTests.IdCompaction; + +public sealed class IdCompactionStartup : TestableStartup +{ + protected override void AddJsonApi(IServiceCollection services) + { + services.AddJsonApi(ConfigureJsonApiOptions, resources: builder => builder.Remove()); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdCompaction/IdCompactionTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdCompaction/IdCompactionTests.cs new file mode 100644 index 0000000000..c515bcab91 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdCompaction/IdCompactionTests.cs @@ -0,0 +1,52 @@ +using System.Net; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.IdCompaction; + +public sealed class IdCompationTests : IClassFixture> +{ + private readonly IntegrationTestContext _testContext; + private readonly IdCompationFakers _fakers = new(); + + public IdCompationTests(IntegrationTestContext testContext) + { + _testContext = testContext; + + testContext.UseController(); + + var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); + options.UseRelativeLinks = true; + options.ClientIdGeneration = ClientIdGenerationMode.Forbidden; + } + + [Fact] + public async Task Can_create_resource() + { + // Arrange + Grant grant = _fakers.Grants.GenerateOne(); + + var requestBody = new + { + data = new + { + type = "grants", + attributes = new + { + name = grant.Name + } + } + }; + + const string route = "/grants"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdCompaction/IdCompationFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdCompaction/IdCompationFakers.cs new file mode 100644 index 0000000000..76a9c39fdd --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdCompaction/IdCompationFakers.cs @@ -0,0 +1,16 @@ +using Bogus; +using TestBuildingBlocks; + +// @formatter:wrap_chained_method_calls chop_if_long +// @formatter:wrap_before_first_method_call true + +namespace JsonApiDotNetCoreTests.IntegrationTests.IdCompaction; + +internal sealed class IdCompationFakers +{ + private readonly Lazy> _lazyGrantFaker = new(() => new Faker() + .MakeDeterministic() + .RuleFor(grant => grant.Name, faker => faker.Company.CompanyName())); + + public Faker Grants => _lazyGrantFaker.Value; +}