From e6615d455483a90dc93f6765f37d5347a8101d9e Mon Sep 17 00:00:00 2001 From: Noah Penza Date: Fri, 5 Sep 2025 05:52:22 +1000 Subject: [PATCH 1/2] Add test cases for new ToBase64String method and ToStringIdentifier new ObsoleteAttribute --- src/FlakeId.Tests/IdExtensionsTests.cs | 106 +++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/src/FlakeId.Tests/IdExtensionsTests.cs b/src/FlakeId.Tests/IdExtensionsTests.cs index c4f3309..8354f74 100644 --- a/src/FlakeId.Tests/IdExtensionsTests.cs +++ b/src/FlakeId.Tests/IdExtensionsTests.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using FlakeId.Extensions; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -45,5 +46,110 @@ public void Id_ToStringIdentifier_ProducesValidId() 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(); + 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(() => IdExtensions.FromBase64String(null)); + Assert.ThrowsException(() => IdExtensions.FromBase64String("")); + Assert.ThrowsException(() => + IdExtensions.FromBase64String("invalid") + ); + Assert.ThrowsException(() => + IdExtensions.FromBase64String("not-base64!") + ); + } + + [TestMethod] + public void Id_ToBase64String_IsShorterThanToStringIdentifier() + { + Id id = Id.Create(); + + string newFormat = id.ToBase64String(); + string legacyFormat = id.ToStringIdentifier(); + + 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); + } } } From 331706d592a703751cea649cc0ba700c371f66c7 Mon Sep 17 00:00:00 2001 From: Noah Penza Date: Fri, 5 Sep 2025 06:34:04 +1000 Subject: [PATCH 2/2] Add ToBase64String extension for Id and corresponding FromBase64String parsing - Introduces efficient, URL-safe Base64 encoding for 64-bit IDs - Handles legacy ToStringIdentifier format for backward compatibility - Includes TryParseFromUrlSafeBase64 and TryParseFromLegacyBase64 helpers --- src/FlakeId/Extensions/IdExtensions.cs | 138 +++++++++++++++++++++++++ 1 file changed, 138 insertions(+) diff --git a/src/FlakeId/Extensions/IdExtensions.cs b/src/FlakeId/Extensions/IdExtensions.cs index 54c830b..66c61b5 100644 --- a/src/FlakeId/Extensions/IdExtensions.cs +++ b/src/FlakeId/Extensions/IdExtensions.cs @@ -47,9 +47,14 @@ public static bool IsSnowflake(this Id id) /// /// 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 instead. /// /// /// + [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(); @@ -57,6 +62,139 @@ public static string ToStringIdentifier(this Id id) return Convert.ToBase64String(Encoding.UTF8.GetBytes(identifier)); } + /// + /// 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. + /// + /// + /// + 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('='); + } + + /// + /// Parses a Base64-encoded string back to an ID. This method can handle both the new URL-safe format + /// (from ) and the legacy format (from ). + /// + /// The Base64-encoded string to parse + /// The parsed ID + /// Thrown when the string cannot be parsed as a valid ID + 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 + ); + } + } + + /// + /// Tries to parse a URL-safe Base64 string into an . + /// Returns true if parsing succeeds; otherwise returns false and sets to . + /// + /// The URL-safe Base64 string to parse. + /// The resulting if successful; otherwise . + /// True if the string was a valid 64-bit ID; false otherwise. + 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; + } + } + + /// + /// Attempts to parse a legacy Base64-encoded string (from ) + /// into an . Returns true if parsing succeeds; otherwise returns false + /// and sets to . + /// + /// The legacy Base64 string to parse. + /// The resulting if successful; otherwise . + /// True if the string was successfully parsed into a valid 64-bit ID; false otherwise. + 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; + } + } + /// /// 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.