diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..32850ac --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + auto-review: + name: Auto review + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Install dependencies + run: composer update --no-progress --optimize-autoloader + + - name: Run phpcs + run: composer phpcs + + - name: Run phpmd + run: composer phpmd + + tests: + name: Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Install dependencies + run: composer update --no-progress --optimize-autoloader + + - name: Run unit tests + env: + XDEBUG_MODE: coverage + run: composer test + + - name: Run mutation tests + run: composer test-mutation diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd34b23 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.idea +vendor +report +composer.lock +.phpunit.result.cache \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a368fdf --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022-2023 Tiny Blocks + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3a04a19 --- /dev/null +++ b/Makefile @@ -0,0 +1,22 @@ +DOCKER_RUN = docker run --rm -it --net=host -v ${PWD}:/app -w /app gustavofreze/php:8.2.6 + +.PHONY: configure test test-no-coverage review show-reports clean + +configure: + @${DOCKER_RUN} composer update --optimize-autoloader + +test: review + @${DOCKER_RUN} composer tests + +test-no-coverage: review + @${DOCKER_RUN} composer tests-no-coverage + +review: + @${DOCKER_RUN} composer review + +show-reports: + @sensible-browser report/coverage/coverage-html/index.html report/coverage/mutation-report.html + +clean: + @sudo chown -R ${USER}:${USER} ${PWD} + @rm -rf report vendor diff --git a/README.md b/README.md index 2027839..50730a2 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,63 @@ -# ksuid -K-Sortable Unique Identifier for PHP. +# Ksuid + +[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) + +* [Overview](#overview) +* [Installation](#installation) +* [How to use](#how-to-use) +* [License](#license) +* [Contributing](#contributing) + +
+ +## Overview + +Ksuid stands for [K-Sortable Unique Identifier](https://segment.com/blog/a-brief-history-of-the-uuid). It's a way to +generate globally unique IDs which are partially chronologically sortable. + +
+ +## Installation + +```bash +composer require tiny-blocks/ksuid +``` + +
+ +## How to use + +The library exposes a concrete implementation through the `Ksuid` class. + +With the `random` method, a new instance of type `Ksuid` is created from a timestamp (_current unix timestamp - EPOCH_) +and a payload (_cryptographically secure pseudo-random bytes_). + +```php +$ksuid = Ksuid::random(); + +echo $ksuid->getValue(); # 2QvY47aUlV3cSyYcpo53FQxgSFg +echo $ksuid->getPayload(); # bdf0a2329620aa70cebe4026ca9ff49c +echo $ksuid->getTimestamp(); # 286235327 +``` + +You can also choose from other factory models. + +```php + +Ksuid::from(payload: hex2bin("9850EEEC191BF4FF26F99315CE43B0C8"), timestamp: 286235327); + +Ksuid::fromPayload(value: '0o5Fs0EELR0fUjHjbCnEtdUwQe3'); + +Ksuid::fromTimestamp(value: 286235327); +``` + +## License + +Math is licensed under [MIT](/LICENSE). + +
+ +## Contributing + +Please follow the [contributing guidelines](https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md) to +contribute to the project. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..38ef282 --- /dev/null +++ b/composer.json @@ -0,0 +1,71 @@ +{ + "name": "tiny-blocks/ksuid", + "type": "library", + "license": "MIT", + "homepage": "https://github.com/tiny-blocks/ksuid", + "description": "K-Sortable Unique Identifier for PHP.", + "prefer-stable": true, + "minimum-stability": "stable", + "keywords": [ + "psr", + "psr-4", + "ksuid", + "psr-12", + "base62", + "unique", + "identifier", + "tiny-blocks" + ], + "authors": [ + { + "name": "Gustavo Freze de Araujo Santos", + "homepage": "https://github.com/gustavofreze" + } + ], + "config": { + "sort-packages": true, + "allow-plugins": { + "infection/extension-installer": true + } + }, + "autoload": { + "psr-4": { + "TinyBlocks\\Ksuid\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "TinyBlocks\\Ksuid\\": "tests/" + } + }, + "require": { + "php": "^8.2", + "tiny-blocks/encoder": "^1" + }, + "require-dev": { + "infection/infection": "^0.26", + "phpmd/phpmd": "^2.12", + "phpunit/phpunit": "^9.5", + "squizlabs/php_codesniffer": "^3.7" + }, + "scripts": { + "phpcs": "phpcs --standard=PSR12 --extensions=php ./src", + "phpmd": "phpmd ./src text phpmd.xml --suffixes php --ignore-violations-on-exit", + "test": "phpunit --log-junit=report/coverage/junit.xml --coverage-xml=report/coverage/coverage-xml --coverage-html=report/coverage/coverage-html tests", + "test-mutation": "infection --only-covered --logger-html=report/coverage/mutation-report.html --coverage=report/coverage --min-msi=100 --min-covered-msi=100 --threads=4", + "test-no-coverage": "phpunit --no-coverage", + "test-mutation-no-coverage": "infection --only-covered --min-msi=100 --threads=4", + "review": [ + "@phpcs", + "@phpmd" + ], + "tests": [ + "@test", + "@test-mutation" + ], + "tests-no-coverage": [ + "@test-no-coverage", + "@test-mutation-no-coverage" + ] + } +} diff --git a/infection.json.dist b/infection.json.dist new file mode 100644 index 0000000..100addb --- /dev/null +++ b/infection.json.dist @@ -0,0 +1,30 @@ +{ + "timeout": 10, + "testFramework": "phpunit", + "tmpDir": "report/", + "source": { + "directories": [ + "src" + ] + }, + "logs": { + "text": "report/logs/infection-text.log", + "summary": "report/logs/infection-summary.log" + }, + "mutators": { + "@default": true, + "Plus": false, + "Minus": false, + "Concat": false, + "CastArray": false, + "GreaterThan": false, + "UnwrapSubstr": false, + "UnwrapStrRepeat": false, + "IncrementInteger": false, + "DecrementInteger": false + }, + "phpUnit": { + "configDir": "", + "customPath": "./vendor/bin/phpunit" + } +} \ No newline at end of file diff --git a/phpmd.xml b/phpmd.xml new file mode 100644 index 0000000..cd9072e --- /dev/null +++ b/phpmd.xml @@ -0,0 +1,57 @@ + + + PHPMD Custom rules + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..3d05fc8 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,25 @@ + + + + + tests + + + + + + src + + + diff --git a/src/Internal/Exceptions/InvalidPayloadSize.php b/src/Internal/Exceptions/InvalidPayloadSize.php new file mode 100644 index 0000000..8eb33c1 --- /dev/null +++ b/src/Internal/Exceptions/InvalidPayloadSize.php @@ -0,0 +1,14 @@ + bytes. Payload size must be exactly <%s> bytes.'; + parent::__construct(sprintf($template, $this->currentSize, $this->payloadBytes)); + } +} diff --git a/src/Internal/Payload.php b/src/Internal/Payload.php new file mode 100644 index 0000000..469a8c0 --- /dev/null +++ b/src/Internal/Payload.php @@ -0,0 +1,43 @@ +value; + } +} diff --git a/src/Internal/Timestamp.php b/src/Internal/Timestamp.php new file mode 100644 index 0000000..393f158 --- /dev/null +++ b/src/Internal/Timestamp.php @@ -0,0 +1,39 @@ +value; + } +} diff --git a/src/Ksuid.php b/src/Ksuid.php new file mode 100644 index 0000000..99165a7 --- /dev/null +++ b/src/Ksuid.php @@ -0,0 +1,63 @@ +getBytes()); + $padding = self::ENCODED_SIZE - strlen($encoded); + + if ($padding > 0) { + $encoded = str_repeat('0', $padding) . $encoded; + } + + return $encoded; + } + + public function getBytes(): string + { + return pack('N', $this->timestamp->getValue()) . $this->payload->getValue(); + } + + public function getPayload(): string + { + return bin2hex($this->payload->getValue()); + } + + public function getTimestamp(): int + { + return $this->timestamp->getValue(); + } +} diff --git a/tests/Internal/PayloadTest.php b/tests/Internal/PayloadTest.php new file mode 100644 index 0000000..ace925a --- /dev/null +++ b/tests/Internal/PayloadTest.php @@ -0,0 +1,40 @@ + bytes. Payload size must be exactly <%s> bytes.'; + + /** @Then an exception indicating that the payload size is invalid should occur */ + $this->expectException(InvalidPayloadSize::class); + $this->expectExceptionMessage(sprintf($template, $currentSize, Payload::PAYLOAD_BYTES)); + + /** @When requesting the creation of the payload with invalid data */ + Payload::from(value: $data); + } + + public function providerForTestExceptionWhenInvalidPayloadSize(): array + { + return [ + [ + 'invalidData' => '', + 'currentSize' => 0 + ], + [ + 'invalidData' => 'ABC', + 'currentSize' => 3 + ] + ]; + } +} diff --git a/tests/KsuidTest.php b/tests/KsuidTest.php new file mode 100644 index 0000000..20d63c7 --- /dev/null +++ b/tests/KsuidTest.php @@ -0,0 +1,62 @@ +getBytes())); + self::assertEquals(Ksuid::ENCODED_SIZE, strlen($ksuid->getValue())); + } + + public function testFromPayload(): void + { + /** @Given a value */ + $value = '0o5Fs0EELR0fUjHjbCnEtdUwQe3'; + + /** @When I generate a KSUID with this value */ + $ksuid = Ksuid::fromPayload(value: $value); + + /** @Then a KSUID must be generated */ + self::assertEquals(20, strlen($ksuid->getBytes())); + self::assertEquals(Ksuid::ENCODED_SIZE, strlen($ksuid->getValue())); + } + + public function testFromTimestamp(): void + { + /** @Given a value */ + $value = time(); + + /** @When I generate a KSUID with this value */ + $ksuid = Ksuid::fromTimestamp(value: $value); + + /** @Then a KSUID must be generated */ + self::assertEquals($value, $ksuid->getTimestamp()); + self::assertEquals(20, strlen($ksuid->getBytes())); + self::assertEquals(Ksuid::ENCODED_SIZE, strlen($ksuid->getValue())); + } + + public function testFromPayloadAndTimestamp(): void + { + /** @Given a payload */ + $payload = hex2bin("9850EEEC191BF4FF26F99315CE43B0C8"); + + /** @And a timestamp */ + $timestamp = 107611700; + + /** @When I generate a KSUID with this value */ + $ksuid = Ksuid::from(payload: $payload, timestamp: $timestamp); + + /** @Then a KSUID must be generated */ + self::assertEquals(bin2hex($payload), $ksuid->getPayload()); + self::assertEquals($timestamp, $ksuid->getTimestamp()); + self::assertEquals('0uk1Hbc9dQ9pxyTqJ93IUrfhdGq', $ksuid->getValue()); + self::assertEquals(20, strlen($ksuid->getBytes())); + self::assertEquals(Ksuid::ENCODED_SIZE, strlen($ksuid->getValue())); + } +}