diff --git a/README.md b/README.md index 7cd7446..e924753 100644 --- a/README.md +++ b/README.md @@ -40,14 +40,16 @@ Global helper functions are autoloaded via `src/functions.php`. =8.2", "ext-bcmath": "*", "psr/simple-cache": "^3.0" }, + "require-dev": { + "infocyph/phpforge": "dev-main" + }, + "replace": { + "abmmhasan/uuid": "*" + }, "minimum-stability": "stable", "prefer-stable": true, + "autoload": { + "psr-4": { + "Infocyph\\UID\\": "src/" + }, + "files": [ + "src/functions.php" + ] + }, "config": { - "sort-packages": true, - "optimize-autoloader": true, - "classmap-authoritative": true, "allow-plugins": { + "ergebnis/composer-normalize": true, "infocyph/phpforge": true, "pestphp/pest-plugin": true - } - }, - "require-dev": { - "infocyph/phpforge": "dev-main" + }, + "classmap-authoritative": true, + "optimize-autoloader": true, + "sort-packages": true } } diff --git a/docs/base-encoding.rst b/docs/base-encoding.rst new file mode 100644 index 0000000..a92daf9 --- /dev/null +++ b/docs/base-encoding.rst @@ -0,0 +1,60 @@ +Base Encoding +============= + +UID exposes base conversion through algorithm-specific APIs and a shared +``Infocyph\\UID\\Support\\BaseEncoder`` utility. + +Supported Bases +--------------- + +- ``16``: lowercase hexadecimal alphabet +- ``32``: ``0-9a-v`` alphabet +- ``36``: ``0-9a-z`` alphabet +- ``58``: Bitcoin-style Base58 alphabet +- ``62``: ``0-9A-Za-z`` alphabet + +Algorithm APIs +-------------- + +Prefer the algorithm-specific methods when converting IDs, because they validate +the canonical input and restore the canonical output type: + +- ``UUID::toBase($uuid, $base)`` / ``UUID::fromBase($encoded, $base)`` +- ``ULID::toBase($ulid, $base)`` / ``ULID::fromBase($encoded, $base)`` +- ``Snowflake::toBase($id, $base)`` / ``Snowflake::fromBase($encoded, $base)`` +- ``Sonyflake::toBase($id, $base)`` / ``Sonyflake::fromBase($encoded, $base)`` +- ``TBSL::toBase($id, $base)`` / ``TBSL::fromBase($encoded, $base)`` + +.. code-block:: php + + 32) && throw new InvalidArgumentException( + 'length must be between 4 and 32', + ); + + self::$counter ??= random_int(0, PHP_INT_MAX); + $hash = hash_init('sha3-512'); + hash_update($hash, (new DateTimeImmutable('now'))->format('Uv')); + hash_update($hash, (string) self::$counter++); + hash_update($hash, bin2hex(random_bytes($length))); + hash_update($hash, self::fingerprint()); + $encoded = self::hexToBase36(hash_final($hash)); + + return substr(str_pad($encoded, $length, '0'), 0, $length); + } + + /** + * Checks whether a CUID2 string is valid. + */ + public static function isValid(string $id, ?int $length = null): bool + { + if ($length !== null && strlen($id) !== $length) { + return false; + } + + return preg_match('/^[0-9a-z]{4,32}$/', $id) === 1; + } + + /** + * Parses CUID2 information. + * + * @return array{isValid: bool, length: int} + */ + public static function parse(string $id, ?int $length = null): array + { + return [ + 'isValid' => self::isValid($id, $length), + 'length' => strlen($id), + ]; + } + + /** + * Generates a fingerprint for the CUID2 algorithm using SHA3-512. + * + * @throws Exception + */ + private static function fingerprint(): string + { + $hash = hash_init('sha3-512'); + hash_update($hash, gethostname() ?: substr(str_shuffle('abcdefghjkmnpqrstvwxyz0123456789'), 0, 32)); + hash_update($hash, (string) random_int(1, 32768)); + hash_update($hash, bin2hex(random_bytes(32))); + + return hash_final($hash); + } + + /** + * Converts a base16 string to base36 using BCMath for large integer safety. + */ + private static function hexToBase36(string $hex): string + { + $hex = strtolower(ltrim($hex, '0')); + if ($hex === '') { + return '0'; + } + + $decimal = '0'; + $hexChars = '0123456789abcdef'; + foreach (str_split($hex) as $char) { + ($value = strpos($hexChars, $char)) !== false || throw new InvalidArgumentException( + 'Invalid hexadecimal string provided', + ); + $decimal = bcadd(bcmul($decimal, '16'), (string) $value); + } + + $encoded = ''; + while (bccomp($decimal, '0') === 1) { + $remainder = (int) bcmod($decimal, '36'); + $encoded = self::$alphabet[$remainder] . $encoded; + $decimal = bcdiv($decimal, '36', 0); + } + + return $encoded; + } +} diff --git a/src/Contracts/IdAlgorithmInterface.php b/src/Contracts/IdAlgorithmInterface.php new file mode 100644 index 0000000..11f69a6 --- /dev/null +++ b/src/Contracts/IdAlgorithmInterface.php @@ -0,0 +1,27 @@ + + */ + public static function parse(string $id): array; +} diff --git a/src/Id.php b/src/Id.php index 4fb4688..12d11fc 100644 --- a/src/Id.php +++ b/src/Id.php @@ -23,12 +23,12 @@ final class Id */ public static function cuid2(int $maxLength = 24): string { - return RandomId::cuid2($maxLength); + return CUID2::generate($maxLength); } public static function cuid2IsValid(string $id): bool { - return RandomId::isCuid2($id); + return CUID2::isValid($id); } /** @@ -36,7 +36,7 @@ public static function cuid2IsValid(string $id): bool */ public static function cuid2Parse(string $id): array { - return RandomId::parseCuid2($id); + return CUID2::parse($id); } public static function deterministic(string $payload, int $length = 24, string $namespace = 'default'): string @@ -57,12 +57,12 @@ public static function ksuid(?DateTimeInterface $dateTime = null): string */ public static function nanoId(int $size = 21): string { - return RandomId::nanoId($size); + return NanoID::generate($size); } public static function nanoIdIsValid(string $id, ?int $size = null): bool { - return RandomId::isNanoId($id, $size); + return NanoID::isValid($id, $size); } /** @@ -70,7 +70,7 @@ public static function nanoIdIsValid(string $id, ?int $size = null): bool */ public static function nanoIdParse(string $id, ?int $size = null): array { - return RandomId::parseNanoId($id, $size); + return NanoID::parse($id, $size); } /** diff --git a/src/KSUID.php b/src/KSUID.php index 3b69098..f798222 100644 --- a/src/KSUID.php +++ b/src/KSUID.php @@ -7,9 +7,10 @@ use DateTimeImmutable; use DateTimeInterface; use Exception; +use Infocyph\UID\Contracts\IdAlgorithmInterface; use Infocyph\UID\Support\BaseEncoder; -final class KSUID +final class KSUID implements IdAlgorithmInterface { private static int $epoch = 1_400_000_000; diff --git a/src/NanoID.php b/src/NanoID.php new file mode 100644 index 0000000..415eca7 --- /dev/null +++ b/src/NanoID.php @@ -0,0 +1,54 @@ + self::isValid($id, $length), + 'length' => strlen($id), + 'alphabet' => 'base64url', + ]; + } +} diff --git a/src/RandomId.php b/src/RandomId.php deleted file mode 100644 index f5bcd7b..0000000 --- a/src/RandomId.php +++ /dev/null @@ -1,155 +0,0 @@ - 32) && throw new InvalidArgumentException( - 'maxLength must be between 4 and 32', - ); - - self::$cuid2counter ??= random_int(0, PHP_INT_MAX); - $hash = hash_init('sha3-512'); - hash_update($hash, (new DateTimeImmutable('now'))->format('Uv')); - hash_update($hash, (string) self::$cuid2counter++); - hash_update($hash, bin2hex(random_bytes($maxLength))); - hash_update($hash, self::cuid2Fingerprint()); - $encoded = self::hexToBase36(hash_final($hash)); - - return substr(str_pad($encoded, $maxLength, '0'), 0, $maxLength); - } - - /** - * Checks whether a CUID2 string is valid. - */ - public static function isCuid2(string $id): bool - { - return preg_match('/^[0-9a-z]{4,32}$/', $id) === 1; - } - - /** - * Checks whether a NanoID string is valid. - * - * @param int|null $size Optional exact size to validate against. - */ - public static function isNanoId(string $id, ?int $size = null): bool - { - if ($size !== null && strlen($id) !== $size) { - return false; - } - - return preg_match('/^[A-Za-z0-9_-]+$/', $id) === 1; - } - - /** - * Generates Nano ID of specified size. - * - * @param int $size The size of the nano ID (default: 21). - * @return string The generated nano ID. - * @throws Exception - */ - public static function nanoId(int $size = 21): string - { - ($size < 1) && throw new InvalidArgumentException('size must be greater than 0'); - - return substr( - str_replace(['+', '/', '='], ['-', '_', ''], base64_encode(random_bytes($size))), - 0, - $size, - ); - } - - /** - * Parses CUID2 information. - * - * @return array{isValid: bool, length: int} - */ - public static function parseCuid2(string $id): array - { - return [ - 'isValid' => self::isCuid2($id), - 'length' => strlen($id), - ]; - } - - /** - * Parses NanoID information. - * - * @param int|null $size Optional expected size. - * @return array{isValid: bool, length: int, alphabet: string} - */ - public static function parseNanoId(string $id, ?int $size = null): array - { - return [ - 'isValid' => self::isNanoId($id, $size), - 'length' => strlen($id), - 'alphabet' => 'base64url', - ]; - } - - /** - * Generates a fingerprint for the cuid2 algorithm using the SHA3-512 hash function. - * - * @return string The hexadecimal representation of the fingerprint. - * @throws Exception - */ - private static function cuid2Fingerprint(): string - { - $hash = hash_init('sha3-512'); - hash_update($hash, gethostname() ?: substr(str_shuffle('abcdefghjkmnpqrstvwxyz0123456789'), 0, 32)); - hash_update($hash, (string) random_int(1, 32768)); - hash_update($hash, bin2hex(random_bytes(32))); - - return hash_final($hash); - } - - /** - * Converts a base16 string to base36 using BCMath for large integer safety. - * - * @param string $hex A hexadecimal string. - */ - private static function hexToBase36(string $hex): string - { - $hex = strtolower(ltrim($hex, '0')); - if ($hex === '') { - return '0'; - } - - $decimal = '0'; - $hexChars = '0123456789abcdef'; - foreach (str_split($hex) as $char) { - ($value = strpos($hexChars, $char)) !== false || throw new InvalidArgumentException( - 'Invalid hexadecimal string provided', - ); - $decimal = bcadd(bcmul($decimal, '16'), (string) $value); - } - - $encoded = ''; - while (bccomp($decimal, '0') === 1) { - $remainder = (int) bcmod($decimal, '36'); - $encoded = self::$cuid2Alphabet[$remainder] . $encoded; - $decimal = bcdiv($decimal, '36', 0); - } - - return $encoded; - } -} diff --git a/src/Snowflake.php b/src/Snowflake.php index b19bab6..09e8c5a 100644 --- a/src/Snowflake.php +++ b/src/Snowflake.php @@ -14,6 +14,7 @@ use Infocyph\UID\Sequence\FilesystemSequenceProvider; use Infocyph\UID\Sequence\SequenceProviderInterface; use Infocyph\UID\Support\BaseEncoder; +use Infocyph\UID\Support\GetSequence; use Infocyph\UID\Support\OutputFormatter; final class Snowflake diff --git a/src/Sonyflake.php b/src/Sonyflake.php index 7780afa..1555fe2 100644 --- a/src/Sonyflake.php +++ b/src/Sonyflake.php @@ -13,6 +13,7 @@ use Infocyph\UID\Exceptions\SonyflakeException; use Infocyph\UID\Sequence\SequenceProviderInterface; use Infocyph\UID\Support\BaseEncoder; +use Infocyph\UID\Support\GetSequence; use Infocyph\UID\Support\OutputFormatter; final class Sonyflake diff --git a/src/GetSequence.php b/src/Support/GetSequence.php similarity index 98% rename from src/GetSequence.php rename to src/Support/GetSequence.php index 8dd00b0..2cd280c 100644 --- a/src/GetSequence.php +++ b/src/Support/GetSequence.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Infocyph\UID; +namespace Infocyph\UID\Support; use Infocyph\UID\Sequence\CallbackSequenceProvider; use Infocyph\UID\Sequence\FilesystemSequenceProvider; diff --git a/src/TBSL.php b/src/TBSL.php index 9de1b06..55335f6 100644 --- a/src/TBSL.php +++ b/src/TBSL.php @@ -12,6 +12,7 @@ use Infocyph\UID\Exceptions\UIDException; use Infocyph\UID\Sequence\SequenceProviderInterface; use Infocyph\UID\Support\BaseEncoder; +use Infocyph\UID\Support\GetSequence; final class TBSL { diff --git a/src/XID.php b/src/XID.php index 7aa7e27..9ecf330 100644 --- a/src/XID.php +++ b/src/XID.php @@ -6,9 +6,10 @@ use DateTimeImmutable; use Exception; +use Infocyph\UID\Contracts\IdAlgorithmInterface; use Infocyph\UID\Support\BaseEncoder; -final class XID +final class XID implements IdAlgorithmInterface { private static ?int $counter = null; diff --git a/src/functions.php b/src/functions.php index e6bcd12..755ed45 100644 --- a/src/functions.php +++ b/src/functions.php @@ -2,14 +2,15 @@ declare(strict_types=1); +use Infocyph\UID\CUID2; use Infocyph\UID\DeterministicId; use Infocyph\UID\Enums\UlidGenerationMode; use Infocyph\UID\Exceptions\FileLockException; use Infocyph\UID\Exceptions\SnowflakeException; use Infocyph\UID\Exceptions\SonyflakeException; use Infocyph\UID\KSUID; +use Infocyph\UID\NanoID; use Infocyph\UID\OpaqueId; -use Infocyph\UID\RandomId; use Infocyph\UID\Snowflake; use Infocyph\UID\Sonyflake; use Infocyph\UID\TBSL; @@ -470,7 +471,7 @@ function tbsl_from_base(string $encoded, int $base): string */ function nanoid(int $size = 21): string { - return RandomId::nanoId($size); + return NanoID::generate($size); } } @@ -480,7 +481,7 @@ function nanoid(int $size = 21): string */ function nanoid_is_valid(string $id, ?int $size = null): bool { - return RandomId::isNanoId($id, $size); + return NanoID::isValid($id, $size); } } @@ -492,7 +493,7 @@ function nanoid_is_valid(string $id, ?int $size = null): bool */ function cuid2(int $maxLength = 24): string { - return RandomId::cuid2($maxLength); + return CUID2::generate($maxLength); } } @@ -502,7 +503,7 @@ function cuid2(int $maxLength = 24): string */ function cuid2_is_valid(string $id): bool { - return RandomId::isCuid2($id); + return CUID2::isValid($id); } } diff --git a/tests/AdditionalIdsTest.php b/tests/AdditionalIdsTest.php index 3a57039..75695f9 100644 --- a/tests/AdditionalIdsTest.php +++ b/tests/AdditionalIdsTest.php @@ -1,5 +1,6 @@ toBeString() ->not()->toBeEmpty() @@ -12,12 +14,12 @@ }); test('CUID2 custom length', function () { - $string = RandomId::cuid2(32); + $string = CUID2::generate(32); expect($string)->toHaveLength(32)->toMatch('/^[0-9a-z]+$/'); }); test('nanoId', function () { - $string = RandomId::nanoId(); + $string = NanoID::generate(); expect($string)->toBeString()->not()->toBeEmpty()->toHaveLength(21); }); @@ -27,17 +29,17 @@ }); test('NanoID and CUID2 validation and parse', function () { - $nano = RandomId::nanoId(12); - $cuid = RandomId::cuid2(24); + $nano = NanoID::generate(12); + $cuid = CUID2::generate(24); - $nanoParsed = RandomId::parseNanoId($nano, 12); - $cuidParsed = RandomId::parseCuid2($cuid); + $nanoParsed = NanoID::parse($nano, 12); + $cuidParsed = CUID2::parse($cuid); - expect(RandomId::isNanoId($nano, 12))->toBeTrue() + expect(NanoID::isValid($nano, 12))->toBeTrue() ->and($nanoParsed['isValid'])->toBeTrue() ->and($nanoParsed['length'])->toBe(12) ->and($nanoParsed['alphabet'])->toBe('base64url') - ->and(RandomId::isCuid2($cuid))->toBeTrue() + ->and(CUID2::isValid($cuid))->toBeTrue() ->and($cuidParsed['isValid'])->toBeTrue() ->and($cuidParsed['length'])->toBe(24) ->and(nanoid_is_valid($nano, 12))->toBeTrue()