Skip to content
Merged
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
106 changes: 106 additions & 0 deletions src/FlakeId.Tests/IdExtensionsTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Linq;
using FlakeId.Extensions;
using Microsoft.VisualStudio.TestTools.UnitTesting;

Expand Down Expand Up @@ -41,9 +42,114 @@
public void Id_ToStringIdentifier_ProducesValidId()
{
Id id = Id.Create();
string s = id.ToStringIdentifier();

Check warning on line 45 in src/FlakeId.Tests/IdExtensionsTests.cs

View workflow job for this annotation

GitHub Actions / build

'IdExtensions.ToStringIdentifier(Id)' is obsolete: 'This legacy method produces longer, non-standard Base64 IDs. Prefer ToBase64String() for compact and standard IDs.'

Check warning on line 45 in src/FlakeId.Tests/IdExtensionsTests.cs

View workflow job for this annotation

GitHub Actions / build

'IdExtensions.ToStringIdentifier(Id)' is obsolete: 'This legacy method produces longer, non-standard Base64 IDs. Prefer ToBase64String() for compact and standard IDs.'

Assert.AreNotEqual(default, s);
}

[TestMethod]
public void Id_ToBase64String_ProducesValidString()
{
Id id = Id.Create();
string base64 = id.ToBase64String();

Assert.IsNotNull(base64);
Assert.IsTrue(base64.Length > 0);
Assert.IsFalse(base64.Contains('+'));
Assert.IsFalse(base64.Contains('/'));
Assert.IsFalse(base64.Contains('='));
}

[TestMethod]
public void Id_ToBase64String_IsUrlSafe()
{
Id id = Id.Create();
string base64 = id.ToBase64String();

Assert.IsTrue(base64.All(c => char.IsLetterOrDigit(c) || c == '-' || c == '_'));
}

[TestMethod]
public void Id_ToBase64String_And_FromBase64String_RoundTrip()
{
Id originalId = Id.Create();
string base64 = originalId.ToBase64String();
Id parsedId = IdExtensions.FromBase64String(base64);

Assert.AreEqual(originalId, parsedId);
}

[TestMethod]
public void Id_FromBase64String_HandlesLegacyToStringIdentifierFormat()
{
Id originalId = Id.Create();
string legacyBase64 = originalId.ToStringIdentifier();

Check warning on line 86 in src/FlakeId.Tests/IdExtensionsTests.cs

View workflow job for this annotation

GitHub Actions / build

'IdExtensions.ToStringIdentifier(Id)' is obsolete: 'This legacy method produces longer, non-standard Base64 IDs. Prefer ToBase64String() for compact and standard IDs.'

Check warning on line 86 in src/FlakeId.Tests/IdExtensionsTests.cs

View workflow job for this annotation

GitHub Actions / build

'IdExtensions.ToStringIdentifier(Id)' is obsolete: 'This legacy method produces longer, non-standard Base64 IDs. Prefer ToBase64String() for compact and standard IDs.'
Id parsedId = IdExtensions.FromBase64String(legacyBase64);

Assert.AreEqual(originalId, parsedId);
}

[TestMethod]
public void Id_FromBase64String_HandlesNewFormat()
{
Id originalId = Id.Create();
string newBase64 = originalId.ToBase64String();
Id parsedId = IdExtensions.FromBase64String(newBase64);

Assert.AreEqual(originalId, parsedId);
}

[TestMethod]
public void Id_FromBase64String_ThrowsOnInvalidInput()
{
Assert.ThrowsException<ArgumentException>(() => IdExtensions.FromBase64String(null));
Assert.ThrowsException<ArgumentException>(() => IdExtensions.FromBase64String(""));
Assert.ThrowsException<ArgumentException>(() =>
IdExtensions.FromBase64String("invalid")
);
Assert.ThrowsException<ArgumentException>(() =>
IdExtensions.FromBase64String("not-base64!")
);
}

[TestMethod]
public void Id_ToBase64String_IsShorterThanToStringIdentifier()
{
Id id = Id.Create();

string newFormat = id.ToBase64String();
string legacyFormat = id.ToStringIdentifier();

Check warning on line 121 in src/FlakeId.Tests/IdExtensionsTests.cs

View workflow job for this annotation

GitHub Actions / build

'IdExtensions.ToStringIdentifier(Id)' is obsolete: 'This legacy method produces longer, non-standard Base64 IDs. Prefer ToBase64String() for compact and standard IDs.'

Check warning on line 121 in src/FlakeId.Tests/IdExtensionsTests.cs

View workflow job for this annotation

GitHub Actions / build

'IdExtensions.ToStringIdentifier(Id)' is obsolete: 'This legacy method produces longer, non-standard Base64 IDs. Prefer ToBase64String() for compact and standard IDs.'

Assert.IsTrue(newFormat.Length <= legacyFormat.Length);
}

[TestMethod]
public void Id_ToBase64String_WithSpecificValues()
{
Id id1 = new Id(1234567890123456789L);
Id id2 = new Id(0L);
Id id3 = new Id(-1L);

string base64_1 = id1.ToBase64String();
string base64_2 = id2.ToBase64String();
string base64_3 = id3.ToBase64String();

Assert.IsNotNull(base64_1);
Assert.IsNotNull(base64_2);
Assert.IsNotNull(base64_3);

Assert.AreEqual(id1, IdExtensions.FromBase64String(base64_1));
Assert.AreEqual(id2, IdExtensions.FromBase64String(base64_2));
Assert.AreEqual(id3, IdExtensions.FromBase64String(base64_3));
}

[TestMethod]
public void Id_ToStringIdentifier_HasObsoleteAttribute()
{
var method = typeof(IdExtensions).GetMethod("ToStringIdentifier");
var obsoleteAttribute = method.GetCustomAttributes(typeof(ObsoleteAttribute), false);

Assert.IsTrue(obsoleteAttribute.Length > 0);
}
}
}
138 changes: 138 additions & 0 deletions src/FlakeId/Extensions/IdExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,16 +47,154 @@ public static bool IsSnowflake(this Id id)

/// <summary>
/// Returns the specified ID as a base 64 encoded string, useful when exposing 64 bit IDs to Node or v8 applications.
/// This method uses the legacy implementation, which first converts the ID to a decimal string and then Base64-encodes it.
/// For improved performance, shorter output, and standard Base64 encoding, use <see cref="ToBase64String(Id)"/> instead.
/// </summary>
/// <param name="id"></param>
/// <returns></returns>
[Obsolete(
"This legacy method produces longer, non-standard Base64 IDs. Prefer ToBase64String() for compact and standard IDs."
)]
public static string ToStringIdentifier(this Id id)
{
string identifier = id.ToString();

return Convert.ToBase64String(Encoding.UTF8.GetBytes(identifier));
}

/// <summary>
/// Returns the specified ID as a URL-safe Base64 encoded string, useful when exposing 64 bit IDs to web applications.
/// This method directly encodes the 64-bit value as Base64, making it more efficient.
/// The result is URL-safe (uses '-' and '_' instead of '+' and '/') and has no padding.
/// </summary>
/// <param name="id"></param>
/// <returns></returns>
public static string ToBase64String(this Id id)
{
long value = id;
byte[] bytes = BitConverter.GetBytes(value);

// convert bytes to a base64 string. remove padding and replace symbols to ensure result is URL safe
return Convert.ToBase64String(bytes).Replace('+', '-').Replace('/', '_').TrimEnd('=');
}

/// <summary>
/// Parses a Base64-encoded string back to an ID. This method can handle both the new URL-safe format
/// (from <see cref="ToBase64String(Id)"/>) and the legacy format (from <see cref="ToStringIdentifier(Id)"/>).
/// </summary>
/// <param name="base64String">The Base64-encoded string to parse</param>
/// <returns>The parsed ID</returns>
/// <exception cref="ArgumentException">Thrown when the string cannot be parsed as a valid ID</exception>
public static Id FromBase64String(string base64String)
{
if (string.IsNullOrEmpty(base64String))
throw new ArgumentException(
"Base64 string cannot be null or empty",
nameof(base64String)
);

try
{
// try parse the new url-safe format first
if (TryParseFromUrlSafeBase64(base64String, out Id id))
return id;

// fall back to legacy format
if (TryParseFromLegacyBase64(base64String, out id))
return id;

// neither method could parse the base 64 string, throw exception
throw new ArgumentException("Invalid Base64 string format", nameof(base64String));
}
catch (Exception ex) when (!(ex is ArgumentException))
{
throw new ArgumentException(
"Invalid Base64 string format",
nameof(base64String),
ex
);
}
}

/// <summary>
/// Tries to parse a URL-safe Base64 string into an <see cref="Id"/>.
/// Returns true if parsing succeeds; otherwise returns false and sets <paramref name="id"/> to <see cref="default(Id)"/>.
/// </summary>
/// <param name="base64String">The URL-safe Base64 string to parse.</param>
/// <param name="id">The resulting <see cref="Id"/> if successful; otherwise <see cref="default(Id)"/>.</param>
/// <returns>True if the string was a valid 64-bit ID; false otherwise.</returns>
private static bool TryParseFromUrlSafeBase64(string base64String, out Id id)
{
id = default;

try
{
// restore url safe characters and padding
string standardBase64 = base64String.Replace('-', '+').Replace('_', '/');

// add padding to make the string length a multiple of 4 (needed for base64)
switch (standardBase64.Length % 4)
{
case 2: standardBase64 += "=="; break;
case 3: standardBase64 += "="; break;
}

byte[] bytes = Convert.FromBase64String(standardBase64);

// check the decoded byte array is exactly 8 bytes (64 bits),
// otherwise it is not a valid 64-bit ID
if (bytes.Length != 8)
return false;

// decode the 8 bytes into a 64-bit long representing the original Id value,
// then create a new Id instance from that value.
long value = BitConverter.ToInt64(bytes, 0);
id = new Id(value);
return true;
}
catch
{
return false;
}
}

/// <summary>
/// Attempts to parse a legacy Base64-encoded string (from <see cref="Id.ToStringIdentifier"/>)
/// into an <see cref="Id"/>. Returns true if parsing succeeds; otherwise returns false
/// and sets <paramref name="id"/> to <see cref="default(Id)"/>.
/// </summary>
/// <param name="base64String">The legacy Base64 string to parse.</param>
/// <param name="id">The resulting <see cref="Id"/> if successful; otherwise <see cref="default(Id)"/>.</param>
/// <returns>True if the string was successfully parsed into a valid 64-bit ID; false otherwise.</returns>
private static bool TryParseFromLegacyBase64(string base64String, out Id id)
{
id = default;

try
{
// decode the Base64 string into a UTF-8 byte array
byte[] bytes = Convert.FromBase64String(base64String);

// convert the byte array into a decimal string representation of the Id
string decimalString = Encoding.UTF8.GetString(bytes);

// try to parse the decimal string into a 64-bit long
if (long.TryParse(decimalString, out long value))
{
// parsed - create a new Id instance
id = new Id(value);
return true;
}

// failed - string doesn't represent valid long Id
return false;
}
catch
{
return false;
}
}

/// <summary>
/// Returns an ID that is valid for the specified timestamp.
/// Note that consecutive calls with the same timestamp will yield different IDs, as the other components of the ID will still differ.
Expand Down
Loading