Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<CompactGuid>,
IEquatable<CompactGuid>,
ISpanParsable<CompactGuid>,
IUtf8SpanParsable<CompactGuid>,
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());
}

/// <inheritdoc />
public static CompactGuid Parse(string s, IFormatProvider? provider = null)
{
ArgumentNullException.ThrowIfNull(s);
return Parse(s.AsSpan(), provider);
}

/// <inheritdoc />
public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, out CompactGuid result)
{
return TryParse(s.AsSpan(), provider, out result);
}

/// <inheritdoc />
public static CompactGuid Parse(ReadOnlySpan<char> s, IFormatProvider? provider = null)
{
if (!TryParse(s, provider, out var result))
{
throw new ArgumentException(null, nameof(s));
}

return result;
}

/// <inheritdoc />
public static bool TryParse(ReadOnlySpan<char> s, IFormatProvider? provider, out CompactGuid result)
{
Span<byte> charBytes = stackalloc byte[IdCharMaxSize];
if (!Encoding.ASCII.TryGetBytes(s, charBytes, out var charBytesLength))
{
result = default;
return false;
}

return TryParse(charBytes[..charBytesLength], provider, out result);
}

/// <inheritdoc />
public static CompactGuid Parse(ReadOnlySpan<byte> utf8Text, IFormatProvider? provider = null)
{
if (!TryParse(utf8Text, provider, out var result))
{
throw new ArgumentException(null, nameof(utf8Text));
}

return result;
}

/// <inheritdoc />
public static bool TryParse(ReadOnlySpan<byte> utf8Text, IFormatProvider? provider, out CompactGuid result)
{
Span<byte> 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;

/// <inheritdoc />
public override bool Equals([NotNullWhen(true)] object? obj)
{
return base.Equals(obj);
}

/// <inheritdoc />
public bool Equals(CompactGuid other)
{
return _value.Equals(other._value);
}

/// <inheritdoc />
public int CompareTo(object? obj)
{
if (obj == null)
{
return 1;
}

if (obj is CompactGuid other)
{
return CompareTo(other);
}

throw new ArgumentException(null, nameof(obj));
}

/// <inheritdoc />
public int CompareTo(CompactGuid other)
{
return _value.CompareTo(other._value);
}

/// <inheritdoc />
public override int GetHashCode()
{
return _value.GetHashCode();
}

/// <inheritdoc />
public override string ToString()
{
Span<byte> valueBytes = stackalloc byte[GuidByteSize];
bool ok = _value.TryWriteBytes(valueBytes);
Debug.Assert(ok);

Span<byte> 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]);
}

/// <inheritdoc />
public string ToString(string? format, IFormatProvider? formatProvider = null)
{
return ToString();
}

/// <inheritdoc />
public bool TryFormat(Span<char> destination, out int charsWritten, ReadOnlySpan<char> format, IFormatProvider? provider = null)
{
Span<byte> 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;
}

/// <inheritdoc />
public bool TryFormat(Span<byte> utf8Destination, out int bytesWritten, ReadOnlySpan<char> format, IFormatProvider? provider = null)
{
Span<byte> 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<CompactGuid>
{
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<byte> idBytes = stackalloc byte[IdCharMaxSize];
_ = value.TryFormat(idBytes, out _, ReadOnlySpan<char>.Empty);
writer.WriteStringValue(idBytes);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;

namespace JsonApiDotNetCoreTests.IntegrationTests.IdCompaction;

public class CompactGuidConverter() : ValueConverter<CompactGuid, Guid>(
id => (Guid)id,
value => new CompactGuid(value));
Original file line number Diff line number Diff line change
@@ -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<CompactGuid>
{
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);
}
}
Original file line number Diff line number Diff line change
@@ -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<TResource>(
IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IResourceService<TResource, CompactGuid> resourceService)
: BaseJsonApiController<TResource, CompactGuid>(options, resourceGraph, loggerFactory, resourceService)
where TResource : class, IIdentifiable<CompactGuid>
{
[HttpGet]
[HttpHead]
public override Task<IActionResult> GetAsync(CancellationToken cancellationToken)
{
return base.GetAsync(cancellationToken);
}

[HttpGet("{id}")]
[HttpHead("{id}")]
public Task<IActionResult> GetAsync([Required] string id, CancellationToken cancellationToken)
{
CompactGuid idValue = CompactGuid.Parse(id);
return base.GetAsync(idValue, cancellationToken);
}

[HttpGet("{id}/{relationshipName}")]
[HttpHead("{id}/{relationshipName}")]
public Task<IActionResult> 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<IActionResult> 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<IActionResult> PostAsync([FromBody] [Required] TResource resource, CancellationToken cancellationToken)
{
return base.PostAsync(resource, cancellationToken);
}

[HttpPost("{id}/relationships/{relationshipName}")]
public Task<IActionResult> PostRelationshipAsync([Required] string id, [Required] [PreserveEmptyString] string relationshipName,
[FromBody] [Required] ISet<IIdentifiable> rightResourceIds, CancellationToken cancellationToken)
{
CompactGuid idValue = CompactGuid.Parse(id);
return base.PostRelationshipAsync(idValue, relationshipName, rightResourceIds, cancellationToken);
}

[HttpPatch("{id}")]
public Task<IActionResult> 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<IActionResult> 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<IActionResult> DeleteAsync([Required] string id, CancellationToken cancellationToken)
{
CompactGuid idValue = CompactGuid.Parse(id);
return base.DeleteAsync(idValue, cancellationToken);
}

[HttpDelete("{id}/relationships/{relationshipName}")]
public Task<IActionResult> DeleteRelationshipAsync([Required] string id, [Required] [PreserveEmptyString] string relationshipName,
[FromBody] [Required] ISet<IIdentifiable> rightResourceIds, CancellationToken cancellationToken)
{
CompactGuid idValue = CompactGuid.Parse(id);
return base.DeleteRelationshipAsync(idValue, relationshipName, rightResourceIds, cancellationToken);
}
}
13 changes: 13 additions & 0 deletions test/JsonApiDotNetCoreTests/IntegrationTests/IdCompaction/Grant.cs
Original file line number Diff line number Diff line change
@@ -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!;
}
Original file line number Diff line number Diff line change
@@ -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<Grant, CompactGuid> resourceService)
: CompactIdentifiableController<Grant>(options, resourceGraph, loggerFactory, resourceService);
Loading
Loading