diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b0d0d1ae..006766a88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org). +## [3.7.1] - 2023-12-17 +### Added +* *Nothing* + +### Changed +* Remove dependency on functional-php library + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#1947](https://github.com/shlinkio/shlink/issues/1947) Fix error when importing short URLs while using Postgres. +* [#1939](https://github.com/shlinkio/shlink/issues/1939) Fine-tune RoadRunner logs to avoid too many useless info. + + ## [3.7.0] - 2023-11-25 ### Added * [#1798](https://github.com/shlinkio/shlink/issues/1798) Experimental support to send visits to an external Matomo instance. diff --git a/composer.json b/composer.json index 0e1b996e9..db0fa4c3a 100644 --- a/composer.json +++ b/composer.json @@ -23,18 +23,17 @@ "doctrine/orm": "^2.16", "endroid/qr-code": "^4.8", "friendsofphp/proxy-manager-lts": "^1.0", - "geoip2/geoip2": "^2.13", + "geoip2/geoip2": "^3.0", "guzzlehttp/guzzle": "^7.5", "happyr/doctrine-specification": "^2.0", "jaybizzle/crawler-detect": "^1.2.116", "laminas/laminas-config": "^3.8", "laminas/laminas-config-aggregator": "^1.13", - "laminas/laminas-diactoros": "^2.25", + "laminas/laminas-diactoros": "^3.3", "laminas/laminas-inputfilter": "^2.27", "laminas/laminas-servicemanager": "^3.21", "laminas/laminas-stdlib": "^3.17", "league/uri": "^6.8", - "lstrojny/functional-php": "^1.17", "matomo/matomo-php-tracker": "^3.2", "mezzio/mezzio": "^3.17", "mezzio/mezzio-fastroute": "^3.10", @@ -46,12 +45,12 @@ "php-middleware/request-id": "^4.1", "pugx/shortid-php": "^1.1", "ramsey/uuid": "^4.7", - "shlinkio/shlink-common": "^5.7", + "shlinkio/shlink-common": "^5.7.1", "shlinkio/shlink-config": "^2.5", "shlinkio/shlink-event-dispatcher": "^3.1", - "shlinkio/shlink-importer": "^5.2", - "shlinkio/shlink-installer": "^8.6", - "shlinkio/shlink-ip-geolocation": "^3.3", + "shlinkio/shlink-importer": "^5.2.1", + "shlinkio/shlink-installer": "^8.6.1", + "shlinkio/shlink-ip-geolocation": "^3.4", "shlinkio/shlink-json": "^1.1", "spiral/roadrunner": "^2023.2", "spiral/roadrunner-cli": "^2.5", @@ -80,6 +79,9 @@ "symfony/var-dumper": "^6.3", "veewee/composer-run-parallel": "^1.3" }, + "conflict": { + "symfony/var-exporter": ">=6.3.9,<=6.4.0" + }, "autoload": { "psr-4": { "Shlinkio\\Shlink\\CLI\\": "module/CLI/src", @@ -88,6 +90,7 @@ }, "files": [ "config/constants.php", + "module/Core/functions/array-utils.php", "module/Core/functions/functions.php" ] }, diff --git a/config/autoload/entity-manager.global.php b/config/autoload/entity-manager.global.php index 588992179..849c91af0 100644 --- a/config/autoload/entity-manager.global.php +++ b/config/autoload/entity-manager.global.php @@ -5,11 +5,11 @@ use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository; use Shlinkio\Shlink\Core\Config\EnvVars; -use function Functional\contains; +use function Shlinkio\Shlink\Core\ArrayUtils\contains; return (static function (): array { $driver = EnvVars::DB_DRIVER->loadFromEnv(); - $isMysqlCompatible = contains(['maria', 'mysql'], $driver); + $isMysqlCompatible = contains($driver, ['maria', 'mysql']); $resolveDriver = static fn () => match ($driver) { 'postgres' => 'pdo_pgsql', diff --git a/config/roadrunner/.rr.dev.yml b/config/roadrunner/.rr.dev.yml index a69a805fa..cdc1f326e 100644 --- a/config/roadrunner/.rr.dev.yml +++ b/config/roadrunner/.rr.dev.yml @@ -1,4 +1,4 @@ -version: '3.0' +version: '3' rpc: listen: tcp://127.0.0.1:6001 @@ -38,3 +38,5 @@ logs: level: debug metrics: level: debug + jobs: + level: debug diff --git a/config/roadrunner/.rr.yml b/config/roadrunner/.rr.yml index b6783c289..0c535b77a 100644 --- a/config/roadrunner/.rr.yml +++ b/config/roadrunner/.rr.yml @@ -1,4 +1,4 @@ -version: '3.0' +version: '3' rpc: listen: tcp://127.0.0.1:6001 @@ -33,4 +33,6 @@ logs: http: mode: 'off' # Disable logging as Shlink handles it internally server: - level: debug # Everything written to worker stderr is logged + level: info + jobs: + level: debug diff --git a/config/test/test_config.global.php b/config/test/test_config.global.php index 1beed0e32..8ae64d7ae 100644 --- a/config/test/test_config.global.php +++ b/config/test/test_config.global.php @@ -28,9 +28,9 @@ use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use function file_exists; -use function Functional\contains; use function Laminas\Stratigility\middleware; use function Shlinkio\Shlink\Config\env; +use function Shlinkio\Shlink\Core\ArrayUtils\contains; use function sprintf; use function sys_get_temp_dir; @@ -41,7 +41,7 @@ $isCliTest = env('TEST_ENV') === 'cli'; $isE2eTest = $isApiTest || $isCliTest; $coverageType = env('GENERATE_COVERAGE'); -$generateCoverage = contains(['yes', 'pretty'], $coverageType); +$generateCoverage = contains($coverageType, ['yes', 'pretty']); $coverage = null; if ($isE2eTest && $generateCoverage) { diff --git a/data/migrations/Version20200105165647.php b/data/migrations/Version20200105165647.php index fb3b79617..26f8cc0a7 100644 --- a/data/migrations/Version20200105165647.php +++ b/data/migrations/Version20200105165647.php @@ -11,7 +11,7 @@ use Doctrine\DBAL\Types\Types; use Doctrine\Migrations\AbstractMigration; -use function Functional\some; +use function Shlinkio\Shlink\Core\ArrayUtils\some; final class Version20200105165647 extends AbstractMigration { @@ -25,7 +25,7 @@ public function preUp(Schema $schema): void $visitLocations = $schema->getTable('visit_locations'); $this->skipIf(some( self::COLUMNS, - fn (string $v, string $newColName) => $visitLocations->hasColumn($newColName), + fn (string $v, string|int $newColName) => $visitLocations->hasColumn((string) $newColName), ), 'New columns already exist'); foreach (self::COLUMNS as $columnName) { diff --git a/data/migrations/Version20200106215144.php b/data/migrations/Version20200106215144.php index 830daf646..f5faba4ed 100644 --- a/data/migrations/Version20200106215144.php +++ b/data/migrations/Version20200106215144.php @@ -7,11 +7,10 @@ use Doctrine\DBAL\Exception; use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Schema\Schema; +use Doctrine\DBAL\Schema\Table; use Doctrine\DBAL\Types\Types; use Doctrine\Migrations\AbstractMigration; -use function Functional\none; - final class Version20200106215144 extends AbstractMigration { private const COLUMNS = ['latitude', 'longitude']; @@ -22,16 +21,24 @@ final class Version20200106215144 extends AbstractMigration public function up(Schema $schema): void { $visitLocations = $schema->getTable('visit_locations'); - $this->skipIf(none( - self::COLUMNS, - fn (string $oldColName) => $visitLocations->hasColumn($oldColName), - ), 'Old columns do not exist'); + $this->skipIf($this->oldColumnsDoNotExist($visitLocations), 'Old columns do not exist'); foreach (self::COLUMNS as $colName) { $visitLocations->dropColumn($colName); } } + public function oldColumnsDoNotExist(Table $visitLocations): bool + { + foreach (self::COLUMNS as $oldColName) { + if ($visitLocations->hasColumn($oldColName)) { + return false; + } + } + + return true; + } + /** * @throws Exception */ diff --git a/data/migrations/Version20200110182849.php b/data/migrations/Version20200110182849.php index b267bfbc3..4b608bb20 100644 --- a/data/migrations/Version20200110182849.php +++ b/data/migrations/Version20200110182849.php @@ -9,9 +9,6 @@ use Doctrine\DBAL\Schema\Schema; use Doctrine\Migrations\AbstractMigration; -use function Functional\each; -use function Functional\partial_left; - final class Version20200110182849 extends AbstractMigration { private const DEFAULT_EMPTY_VALUE = ''; @@ -31,11 +28,11 @@ final class Version20200110182849 extends AbstractMigration public function up(Schema $schema): void { - each( - self::COLUMN_DEFAULTS_MAP, - fn (array $columns, string $tableName) => - each($columns, partial_left([$this, 'setDefaultValueForColumnInTable'], $tableName)), - ); + foreach (self::COLUMN_DEFAULTS_MAP as $tableName => $columns) { + foreach ($columns as $columnName) { + $this->setDefaultValueForColumnInTable($tableName, $columnName); + } + } } /** diff --git a/docker-compose.yml b/docker-compose.yml index e44ca82b7..f33693ad7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -199,7 +199,7 @@ services: shlink_swagger_ui: container_name: shlink_swagger_ui - image: swaggerapi/swagger-ui:v5.9.1 + image: swaggerapi/swagger-ui:v5.10.3 ports: - "8005:8080" volumes: diff --git a/module/CLI/src/Command/Api/ListKeysCommand.php b/module/CLI/src/Command/Api/ListKeysCommand.php index 4fd4b0050..b55dcd7d9 100644 --- a/module/CLI/src/Command/Api/ListKeysCommand.php +++ b/module/CLI/src/Command/Api/ListKeysCommand.php @@ -15,7 +15,7 @@ use Symfony\Component\Console\Output\OutputInterface; use function array_filter; -use function Functional\map; +use function array_map; use function implode; use function sprintf; @@ -49,7 +49,7 @@ protected function execute(InputInterface $input, OutputInterface $output): ?int { $enabledOnly = $input->getOption('enabled-only'); - $rows = map($this->apiKeyService->listKeys($enabledOnly), function (ApiKey $apiKey) use ($enabledOnly) { + $rows = array_map(function (ApiKey $apiKey) use ($enabledOnly) { $expiration = $apiKey->getExpirationDate(); $messagePattern = $this->determineMessagePattern($apiKey); @@ -64,7 +64,7 @@ protected function execute(InputInterface $input, OutputInterface $output): ?int )); return $rowData; - }); + }, $this->apiKeyService->listKeys($enabledOnly)); ShlinkTable::withRowSeparators($output)->render(array_filter([ 'Key', diff --git a/module/CLI/src/Command/Db/CreateDatabaseCommand.php b/module/CLI/src/Command/Db/CreateDatabaseCommand.php index 129db1e0b..53b854d1d 100644 --- a/module/CLI/src/Command/Db/CreateDatabaseCommand.php +++ b/module/CLI/src/Command/Db/CreateDatabaseCommand.php @@ -16,9 +16,9 @@ use Symfony\Component\Process\PhpExecutableFinder; use Throwable; -use function Functional\contains; -use function Functional\map; -use function Functional\some; +use function array_map; +use function Shlinkio\Shlink\Core\ArrayUtils\contains; +use function Shlinkio\Shlink\Core\ArrayUtils\some; class CreateDatabaseCommand extends AbstractDatabaseCommand { @@ -70,11 +70,11 @@ private function databaseTablesExist(): bool { $existingTables = $this->ensureDatabaseExistsAndGetTables(); $allMetadata = $this->em->getMetadataFactory()->getAllMetadata(); - $shlinkTables = map($allMetadata, static fn (ClassMetadata $metadata) => $metadata->getTableName()); + $shlinkTables = array_map(static fn (ClassMetadata $metadata) => $metadata->getTableName(), $allMetadata); // If at least one of the shlink tables exist, we will consider the database exists somehow. // Any other inconsistency will be taken care of by the migrations. - return some($shlinkTables, static fn (string $shlinkTable) => contains($existingTables, $shlinkTable)); + return some($shlinkTables, static fn (string $shlinkTable) => contains($shlinkTable, $existingTables)); } private function ensureDatabaseExistsAndGetTables(): array diff --git a/module/CLI/src/Command/Domain/DomainRedirectsCommand.php b/module/CLI/src/Command/Domain/DomainRedirectsCommand.php index 4a3f8062b..bf08e7f3d 100644 --- a/module/CLI/src/Command/Domain/DomainRedirectsCommand.php +++ b/module/CLI/src/Command/Domain/DomainRedirectsCommand.php @@ -14,8 +14,8 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; -use function Functional\filter; -use function Functional\invoke; +use function array_filter; +use function array_map; use function sprintf; use function str_contains; @@ -23,7 +23,7 @@ class DomainRedirectsCommand extends Command { public const NAME = 'domain:redirects'; - public function __construct(private DomainServiceInterface $domainService) + public function __construct(private readonly DomainServiceInterface $domainService) { parent::__construct(); } @@ -52,9 +52,9 @@ protected function interact(InputInterface $input, OutputInterface $output): voi $askNewDomain = static fn () => $io->ask('Domain authority for which you want to set specific redirects'); /** @var string[] $availableDomains */ - $availableDomains = invoke( - filter($this->domainService->listDomains(), static fn (DomainItem $item) => ! $item->isDefault), - 'toString', + $availableDomains = array_map( + static fn (DomainItem $item) => $item->toString(), + array_filter($this->domainService->listDomains(), static fn (DomainItem $item) => ! $item->isDefault), ); if (empty($availableDomains)) { $input->setArgument('domain', $askNewDomain()); diff --git a/module/CLI/src/Command/Domain/ListDomainsCommand.php b/module/CLI/src/Command/Domain/ListDomainsCommand.php index 11a0f5b9f..501072928 100644 --- a/module/CLI/src/Command/Domain/ListDomainsCommand.php +++ b/module/CLI/src/Command/Domain/ListDomainsCommand.php @@ -14,13 +14,13 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -use function Functional\map; +use function array_map; class ListDomainsCommand extends Command { public const NAME = 'domain:list'; - public function __construct(private DomainServiceInterface $domainService) + public function __construct(private readonly DomainServiceInterface $domainService) { parent::__construct(); } @@ -47,7 +47,7 @@ protected function execute(InputInterface $input, OutputInterface $output): ?int $table->render( $showRedirects ? [...$commonFields, '"Not found" redirects'] : $commonFields, - map($domains, function (DomainItem $domain) use ($showRedirects) { + array_map(function (DomainItem $domain) use ($showRedirects) { $commonValues = [$domain->toString(), $domain->isDefault ? 'Yes' : 'No']; return $showRedirects @@ -56,7 +56,7 @@ protected function execute(InputInterface $input, OutputInterface $output): ?int $this->notFoundRedirectsToString($domain->notFoundRedirectConfig), ] : $commonValues; - }), + }, $domains), ); return ExitCode::EXIT_SUCCESS; diff --git a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php index f55f247d6..64418aa62 100644 --- a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php @@ -20,10 +20,9 @@ use Symfony\Component\Console\Style\SymfonyStyle; use function array_map; +use function array_unique; use function explode; -use function Functional\curry; -use function Functional\flatten; -use function Functional\unique; +use function Shlinkio\Shlink\Core\ArrayUtils\flatten; use function sprintf; class CreateShortUrlCommand extends Command @@ -144,8 +143,8 @@ protected function execute(InputInterface $input, OutputInterface $output): ?int return ExitCode::EXIT_FAILURE; } - $explodeWithComma = curry(explode(...))(','); - $tags = unique(flatten(array_map($explodeWithComma, $input->getOption('tags')))); + $explodeWithComma = static fn (string $tag) => explode(',', $tag); + $tags = array_unique(flatten(array_map($explodeWithComma, $input->getOption('tags')))); $customSlug = $input->getOption('custom-slug'); $maxVisits = $input->getOption('max-visits'); $shortCodeLength = $input->getOption('short-code-length') ?? $this->options->defaultShortCodesLength; diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index 14ea18515..c9497daf2 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -23,9 +23,9 @@ use Symfony\Component\Console\Style\SymfonyStyle; use function array_keys; +use function array_map; use function array_pad; use function explode; -use function Functional\map; use function implode; use function sprintf; @@ -184,10 +184,10 @@ private function renderPage( ): Paginator { $shortUrls = $this->shortUrlService->listShortUrls($params); - $rows = map($shortUrls, function (ShortUrl $shortUrl) use ($columnsMap) { + $rows = array_map(function (ShortUrl $shortUrl) use ($columnsMap) { $rawShortUrl = $this->transformer->transform($shortUrl); - return map($columnsMap, fn (callable $call) => $call($rawShortUrl, $shortUrl)); - }); + return array_map(fn (callable $call) => $call($rawShortUrl, $shortUrl), $columnsMap); + }, [...$shortUrls]); ShlinkTable::default($output)->render( array_keys($columnsMap), diff --git a/module/CLI/src/Command/Tag/ListTagsCommand.php b/module/CLI/src/Command/Tag/ListTagsCommand.php index 41ca9b601..d56e4101a 100644 --- a/module/CLI/src/Command/Tag/ListTagsCommand.php +++ b/module/CLI/src/Command/Tag/ListTagsCommand.php @@ -13,13 +13,13 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use function Functional\map; +use function array_map; class ListTagsCommand extends Command { public const NAME = 'tag:list'; - public function __construct(private TagServiceInterface $tagService) + public function __construct(private readonly TagServiceInterface $tagService) { parent::__construct(); } @@ -44,9 +44,9 @@ private function getTagsRows(): array return [['No tags found', '-', '-']]; } - return map( - $tags, + return array_map( static fn (TagInfo $tagInfo) => [$tagInfo->tag, $tagInfo->shortUrlsCount, $tagInfo->visitsSummary->total], + [...$tags], ); } } diff --git a/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php b/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php index ba5186564..a15eb5e72 100644 --- a/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php +++ b/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php @@ -17,9 +17,9 @@ use Symfony\Component\Console\Output\OutputInterface; use function array_keys; -use function Functional\map; -use function Functional\select_keys; +use function array_map; use function Shlinkio\Shlink\Common\buildDateRange; +use function Shlinkio\Shlink\Core\ArrayUtils\select_keys; use function Shlinkio\Shlink\Core\camelCaseToHumanFriendly; abstract class AbstractVisitsListCommand extends Command @@ -49,7 +49,7 @@ final protected function execute(InputInterface $input, OutputInterface $output) private function resolveRowsAndHeaders(Paginator $paginator): array { $extraKeys = []; - $rows = map($paginator->getCurrentPageResults(), function (Visit $visit) use (&$extraKeys) { + $rows = array_map(function (Visit $visit) use (&$extraKeys) { $extraFields = $this->mapExtraFields($visit); $extraKeys = array_keys($extraFields); @@ -60,9 +60,10 @@ private function resolveRowsAndHeaders(Paginator $paginator): array ...$extraFields, ]; + // Filter out unknown keys return select_keys($rowData, ['referer', 'date', 'userAgent', 'country', 'city', ...$extraKeys]); - }); - $extra = map($extraKeys, camelCaseToHumanFriendly(...)); + }, [...$paginator->getCurrentPageResults()]); + $extra = array_map(camelCaseToHumanFriendly(...), $extraKeys); return [ $rows, diff --git a/module/CLI/src/Util/ShlinkTable.php b/module/CLI/src/Util/ShlinkTable.php index cd38e5cd0..c421c6131 100644 --- a/module/CLI/src/Util/ShlinkTable.php +++ b/module/CLI/src/Util/ShlinkTable.php @@ -8,30 +8,30 @@ use Symfony\Component\Console\Helper\TableSeparator; use Symfony\Component\Console\Output\OutputInterface; -use function Functional\intersperse; +use function array_pop; final class ShlinkTable { private const DEFAULT_STYLE_NAME = 'default'; private const TABLE_TITLE_STYLE = ' %s '; - private function __construct(private readonly Table $baseTable, private readonly bool $withRowSeparators) + private function __construct(private readonly Table $baseTable, private readonly bool $withRowSeparators = false) { } public static function default(OutputInterface $output): self { - return new self(new Table($output), false); + return new self(new Table($output)); } public static function withRowSeparators(OutputInterface $output): self { - return new self(new Table($output), true); + return new self(new Table($output), withRowSeparators: true); } public static function fromBaseTable(Table $baseTable): self { - return new self($baseTable, false); + return new self($baseTable); } public function render(array $headers, array $rows, ?string $footerTitle = null, ?string $headerTitle = null): void @@ -39,7 +39,7 @@ public function render(array $headers, array $rows, ?string $footerTitle = null, $style = Table::getStyleDefinition(self::DEFAULT_STYLE_NAME); $style->setFooterTitleFormat(self::TABLE_TITLE_STYLE) ->setHeaderTitleFormat(self::TABLE_TITLE_STYLE); - $tableRows = $this->withRowSeparators ? intersperse($rows, new TableSeparator()) : $rows; + $tableRows = $this->withRowSeparators ? $this->addRowSeparators($rows) : $rows; $table = clone $this->baseTable; $table->setStyle($style) @@ -49,4 +49,20 @@ public function render(array $headers, array $rows, ?string $footerTitle = null, ->setHeaderTitle($headerTitle) ->render(); } + + private function addRowSeparators(array $rows): array + { + $aggregation = []; + $separator = new TableSeparator(); + + foreach ($rows as $row) { + $aggregation[] = $row; + $aggregation[] = $separator; + } + + // Remove last separator + array_pop($aggregation); + + return $aggregation; + } } diff --git a/module/CLI/test/ApiKey/RoleResolverTest.php b/module/CLI/test/ApiKey/RoleResolverTest.php index 7aecda6d2..cbd4f0fa1 100644 --- a/module/CLI/test/ApiKey/RoleResolverTest.php +++ b/module/CLI/test/ApiKey/RoleResolverTest.php @@ -16,8 +16,6 @@ use Shlinkio\Shlink\Rest\ApiKey\Role; use Symfony\Component\Console\Input\InputInterface; -use function Functional\map; - class RoleResolverTest extends TestCase { private RoleResolver $resolver; @@ -49,10 +47,13 @@ public static function provideRoles(): iterable { $domain = self::domainWithId(Domain::withAuthority('example.com')); $buildInput = static fn (array $definition) => function (TestCase $test) use ($definition): InputInterface { + $returnMap = []; + foreach ($definition as $param => $returnValue) { + $returnMap[] = [$param, $returnValue]; + } + $input = $test->createStub(InputInterface::class); - $input->method('getOption')->willReturnMap( - map($definition, static fn (mixed $returnValue, string $param) => [$param, $returnValue]), - ); + $input->method('getOption')->willReturnMap($returnMap); return $input; }; diff --git a/module/CLI/test/GeoLite/GeolocationDbUpdaterTest.php b/module/CLI/test/GeoLite/GeolocationDbUpdaterTest.php index 9d32ca79e..0f911db8f 100644 --- a/module/CLI/test/GeoLite/GeolocationDbUpdaterTest.php +++ b/module/CLI/test/GeoLite/GeolocationDbUpdaterTest.php @@ -21,7 +21,7 @@ use Symfony\Component\Lock; use Throwable; -use function Functional\map; +use function array_map; use function range; class GeolocationDbUpdaterTest extends TestCase @@ -128,7 +128,7 @@ public static function provideSmallDays(): iterable return [$days % 2 === 0 ? $timestamp : (string) $timestamp]; }; - return map(range(0, 34), $generateParamsWithTimestamp); + return array_map($generateParamsWithTimestamp, range(0, 34)); } #[Test] diff --git a/module/Core/functions/array-utils.php b/module/Core/functions/array-utils.php new file mode 100644 index 000000000..5fb636e69 --- /dev/null +++ b/module/Core/functions/array-utils.php @@ -0,0 +1,74 @@ + [...$carry, ...$value], + initial: [], + ); +} + +/** + * Checks if a callback returns true for at least one item in a collection. + * @param callable(mixed $value, mixed $key): bool $callback + */ +function some(iterable $collection, callable $callback): bool +{ + foreach ($collection as $key => $value) { + if ($callback($value, $key)) { + return true; + } + } + + return false; +} + +/** + * Checks if a callback returns true for all item in a collection. + * @param callable(mixed $value, string|number $key): bool $callback + */ +function every(iterable $collection, callable $callback): bool +{ + foreach ($collection as $key => $value) { + if (! $callback($value, $key)) { + return false; + } + } + + return true; +} + +/** + * Returns an array containing only those entries in the array whose key is in the supplied keys. + */ +function select_keys(array $array, array $keys): array +{ + return array_filter( + $array, + static fn (string $key) => contains( + $key, + $keys, + ), + ARRAY_FILTER_USE_KEY, + ); +} diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php index b6acbb357..d07bc9e21 100644 --- a/module/Core/functions/functions.php +++ b/module/Core/functions/functions.php @@ -6,7 +6,6 @@ use BackedEnum; use Cake\Chronos\Chronos; -use Cake\Chronos\ChronosInterface; use DateTimeInterface; use Doctrine\ORM\Mapping\Builder\FieldBuilder; use Jaybizzle\CrawlerDetect\CrawlerDetect; @@ -17,9 +16,10 @@ use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; +use function array_keys; +use function array_map; +use function array_reduce; use function date_default_timezone_get; -use function Functional\map; -use function Functional\reduce_left; use function is_array; use function print_r; use function Shlinkio\Shlink\Common\buildDateRange; @@ -57,7 +57,7 @@ function parseDateRangeFromQuery(array $query, string $startDateName, string $en /** * @return ($date is null ? null : Chronos) */ -function normalizeOptionalDate(string|DateTimeInterface|ChronosInterface|null $date): ?Chronos +function normalizeOptionalDate(string|DateTimeInterface|Chronos|null $date): ?Chronos { $parsedDate = match (true) { $date === null || $date instanceof Chronos => $date, @@ -68,7 +68,7 @@ function normalizeOptionalDate(string|DateTimeInterface|ChronosInterface|null $d return $parsedDate?->setTimezone(date_default_timezone_get()); } -function normalizeDate(string|DateTimeInterface|ChronosInterface $date): Chronos +function normalizeDate(string|DateTimeInterface|Chronos $date): Chronos { return normalizeOptionalDate($date); } @@ -94,10 +94,12 @@ function getNonEmptyOptionalValueFromInputFilter(InputFilter $inputFilter, strin function arrayToString(array $array, int $indentSize = 4): string { $indent = str_repeat(' ', $indentSize); + $names = array_keys($array); $index = 0; - return reduce_left($array, static function ($messages, string $name, $_, string $acc) use (&$index, $indent) { + return array_reduce($names, static function (string $acc, string $name) use (&$index, $indent, $array) { $index++; + $messages = $array[$name]; return $acc . sprintf( "%s%s'%s' => %s", @@ -177,6 +179,6 @@ function enumValues(string $enum): array } return $cache[$enum] ?? ( - $cache[$enum] = map($enum::cases(), static fn (BackedEnum $type) => (string) $type->value) + $cache[$enum] = array_map(static fn (BackedEnum $type) => (string) $type->value, $enum::cases()) ); } diff --git a/module/Core/src/Action/Model/QrCodeParams.php b/module/Core/src/Action/Model/QrCodeParams.php index 306c2b444..05181f20b 100644 --- a/module/Core/src/Action/Model/QrCodeParams.php +++ b/module/Core/src/Action/Model/QrCodeParams.php @@ -18,7 +18,7 @@ use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\Options\QrCodeOptions; -use function Functional\contains; +use function Shlinkio\Shlink\Core\ArrayUtils\contains; use function strtolower; use function trim; @@ -74,7 +74,7 @@ private static function resolveMargin(array $query, QrCodeOptions $defaults): in private static function resolveWriter(array $query, QrCodeOptions $defaults): WriterInterface { $qFormat = self::normalizeParam($query['format'] ?? ''); - $format = contains(self::SUPPORTED_FORMATS, $qFormat) ? $qFormat : self::normalizeParam($defaults->format); + $format = contains($qFormat, self::SUPPORTED_FORMATS) ? $qFormat : self::normalizeParam($defaults->format); return match ($format) { 'svg' => new SvgWriter(), diff --git a/module/Core/src/Config/NotFoundRedirectResolver.php b/module/Core/src/Config/NotFoundRedirectResolver.php index 3ab2e7405..ce5401d2e 100644 --- a/module/Core/src/Config/NotFoundRedirectResolver.php +++ b/module/Core/src/Config/NotFoundRedirectResolver.php @@ -12,8 +12,6 @@ use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType; use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface; -use function Functional\compose; -use function Functional\id; use function str_replace; use function urlencode; @@ -23,8 +21,8 @@ class NotFoundRedirectResolver implements NotFoundRedirectResolverInterface private const ORIGINAL_PATH_PLACEHOLDER = '{ORIGINAL_PATH}'; public function __construct( - private RedirectResponseHelperInterface $redirectResponseHelper, - private LoggerInterface $logger, + private readonly RedirectResponseHelperInterface $redirectResponseHelper, + private readonly LoggerInterface $logger, ) { } @@ -52,9 +50,6 @@ public function resolveRedirectResponse( private function resolvePlaceholders(UriInterface $currentUri, string $redirectUrl): string { - $domain = $currentUri->getAuthority(); - $path = $currentUri->getPath(); - try { $redirectUri = Uri::createFromString($redirectUrl); } catch (SyntaxError $e) { @@ -65,18 +60,32 @@ private function resolvePlaceholders(UriInterface $currentUri, string $redirectU return $redirectUrl; } - $replacePlaceholderForPattern = static fn (string $pattern, string $replace, callable $modifier) => - static fn (?string $value) => - $value === null ? null : str_replace($modifier($pattern), $modifier($replace), $value); - $replacePlaceholders = static fn (callable $modifier) => compose( - $replacePlaceholderForPattern(self::DOMAIN_PLACEHOLDER, $domain, $modifier), - $replacePlaceholderForPattern(self::ORIGINAL_PATH_PLACEHOLDER, $path, $modifier), - ); - $replacePlaceholdersInPath = compose( - $replacePlaceholders(id(...)), - static fn (?string $path) => $path === null ? null : str_replace('//', '/', $path), + $path = $currentUri->getPath(); + $domain = $currentUri->getAuthority(); + + $replacePlaceholderForPattern = static fn (string $pattern, string $replace, ?string $value): string|null => + $value === null ? null : str_replace($pattern, $replace, $value); + + $replacePlaceholders = static function ( + callable $modifier, + ?string $value, + ) use ( + $replacePlaceholderForPattern, + $path, + $domain, + ): string|null { + $value = $replacePlaceholderForPattern($modifier(self::DOMAIN_PLACEHOLDER), $modifier($domain), $value); + return $replacePlaceholderForPattern($modifier(self::ORIGINAL_PATH_PLACEHOLDER), $modifier($path), $value); + }; + + $replacePlaceholdersInPath = static function (string $path) use ($replacePlaceholders): string { + $result = $replacePlaceholders(static fn (mixed $v) => $v, $path); + return str_replace('//', '/', $result ?? ''); + }; + $replacePlaceholdersInQuery = static fn (?string $query): string|null => $replacePlaceholders( + urlencode(...), + $query, ); - $replacePlaceholdersInQuery = $replacePlaceholders(urlencode(...)); return $redirectUri ->withPath($replacePlaceholdersInPath($redirectUri->getPath())) diff --git a/module/Core/src/Config/PostProcessor/BasePathPrefixer.php b/module/Core/src/Config/PostProcessor/BasePathPrefixer.php index 619e60563..616759f18 100644 --- a/module/Core/src/Config/PostProcessor/BasePathPrefixer.php +++ b/module/Core/src/Config/PostProcessor/BasePathPrefixer.php @@ -4,7 +4,7 @@ namespace Shlinkio\Shlink\Core\Config\PostProcessor; -use function Functional\map; +use function array_map; class BasePathPrefixer { @@ -23,13 +23,13 @@ public function __invoke(array $config): array private function prefixPathsWithBasePath(string $configKey, array $config, string $basePath): array { - return map($config[$configKey] ?? [], function (array $element) use ($basePath) { + return array_map(function (array $element) use ($basePath) { if (! isset($element['path'])) { return $element; } $element['path'] = $basePath . $element['path']; return $element; - }); + }, $config[$configKey] ?? []); } } diff --git a/module/Core/src/Config/PostProcessor/MultiSegmentSlugProcessor.php b/module/Core/src/Config/PostProcessor/MultiSegmentSlugProcessor.php index 339450636..585f78b6b 100644 --- a/module/Core/src/Config/PostProcessor/MultiSegmentSlugProcessor.php +++ b/module/Core/src/Config/PostProcessor/MultiSegmentSlugProcessor.php @@ -4,7 +4,7 @@ namespace Shlinkio\Shlink\Core\Config\PostProcessor; -use function Functional\map; +use function array_map; use function str_replace; class MultiSegmentSlugProcessor @@ -19,11 +19,11 @@ public function __invoke(array $config): array return $config; } - $config['routes'] = map($config['routes'] ?? [], static function (array $route): array { + $config['routes'] = array_map(static function (array $route): array { ['path' => $path] = $route; $route['path'] = str_replace(self::SINGLE_SEGMENT_PATTERN, self::MULTI_SEGMENT_PATTERN, $path); return $route; - }); + }, $config['routes'] ?? []); return $config; } diff --git a/module/Core/src/Config/PostProcessor/ShortUrlMethodsProcessor.php b/module/Core/src/Config/PostProcessor/ShortUrlMethodsProcessor.php index 05ecdb6c9..42f00889d 100644 --- a/module/Core/src/Config/PostProcessor/ShortUrlMethodsProcessor.php +++ b/module/Core/src/Config/PostProcessor/ShortUrlMethodsProcessor.php @@ -9,25 +9,34 @@ use Shlinkio\Shlink\Core\Action\RedirectAction; use Shlinkio\Shlink\Core\Util\RedirectStatus; -use function array_values; -use function count; -use function Functional\partition; - use const Shlinkio\Shlink\DEFAULT_REDIRECT_STATUS_CODE; +/** + * Sets the appropriate allowed methods on the redirect route, based on the redirect status code that was configured. + * * For "legacy" status codes (301 and 302) the redirect URL will work only on GET method. + * * For other status codes (307 and 308) the redirect URL will work on any method. + */ class ShortUrlMethodsProcessor { public function __invoke(array $config): array { - [$redirectRoutes, $rest] = partition( - $config['routes'] ?? [], - static fn (array $route) => $route['name'] === RedirectAction::class, - ); - if (count($redirectRoutes) === 0) { + $allRoutes = $config['routes'] ?? []; + $redirectRoute = null; + $rest = []; + + // Get default route from routes array + foreach ($allRoutes as $route) { + if ($route['name'] === RedirectAction::class) { + $redirectRoute ??= $route; + } else { + $rest[] = $route; + } + } + + if ($redirectRoute === null) { return $config; } - [$redirectRoute] = array_values($redirectRoutes); $redirectStatus = RedirectStatus::tryFrom( $config['redirects']['redirect_status_code'] ?? 0, ) ?? DEFAULT_REDIRECT_STATUS_CODE; diff --git a/module/Core/src/Domain/DomainService.php b/module/Core/src/Domain/DomainService.php index 703f77fd2..93adbf5f1 100644 --- a/module/Core/src/Domain/DomainService.php +++ b/module/Core/src/Domain/DomainService.php @@ -14,9 +14,7 @@ use Shlinkio\Shlink\Rest\ApiKey\Role; use Shlinkio\Shlink\Rest\Entity\ApiKey; -use function Functional\first; -use function Functional\group; -use function Functional\map; +use function array_map; class DomainService implements DomainServiceInterface { @@ -30,7 +28,7 @@ public function __construct(private readonly EntityManagerInterface $em, private public function listDomains(?ApiKey $apiKey = null): array { [$default, $domains] = $this->defaultDomainAndRest($apiKey); - $mappedDomains = map($domains, fn (Domain $domain) => DomainItem::forNonDefaultDomain($domain)); + $mappedDomains = array_map(fn (Domain $domain) => DomainItem::forNonDefaultDomain($domain), $domains); if ($apiKey?->hasRole(Role::DOMAIN_SPECIFIC)) { return $mappedDomains; @@ -49,12 +47,19 @@ private function defaultDomainAndRest(?ApiKey $apiKey): array { /** @var DomainRepositoryInterface $repo */ $repo = $this->em->getRepository(Domain::class); - $groups = group( - $repo->findDomains($apiKey), - fn (Domain $domain) => $domain->authority === $this->defaultDomain ? 'default' : 'domains', - ); + $allDomains = $repo->findDomains($apiKey); + $defaultDomain = null; + $restOfDomains = []; + + foreach ($allDomains as $domain) { + if ($domain->authority === $this->defaultDomain) { + $defaultDomain = $domain; + } else { + $restOfDomains[] = $domain; + } + } - return [first($groups['default'] ?? []), $groups['domains'] ?? []]; + return [$defaultDomain, $restOfDomains]; } /** diff --git a/module/Core/src/EventDispatcher/Async/AbstractNotifyVisitListener.php b/module/Core/src/EventDispatcher/Async/AbstractNotifyVisitListener.php index dae9130fa..3ec9417cc 100644 --- a/module/Core/src/EventDispatcher/Async/AbstractNotifyVisitListener.php +++ b/module/Core/src/EventDispatcher/Async/AbstractNotifyVisitListener.php @@ -13,7 +13,7 @@ use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Throwable; -use function Functional\each; +use function array_walk; abstract class AbstractNotifyVisitListener extends AbstractAsyncListener { @@ -46,7 +46,7 @@ public function __invoke(VisitLocated $visitLocated): void $updates = $this->determineUpdatesForVisit($visit); try { - each($updates, fn (Update $update) => $this->publishingHelper->publishUpdate($update)); + array_walk($updates, fn (Update $update) => $this->publishingHelper->publishUpdate($update)); } catch (Throwable $e) { $this->logger->debug( 'Error while trying to notify {name} with new visit. {e}', diff --git a/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php b/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php index 317821b1c..028c3c134 100644 --- a/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php +++ b/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php @@ -19,18 +19,18 @@ use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Throwable; -use function Functional\map; +use function array_map; /** @deprecated */ class NotifyVisitToWebHooks { public function __construct( - private ClientInterface $httpClient, - private EntityManagerInterface $em, - private LoggerInterface $logger, - private WebhookOptions $webhookOptions, - private DataTransformerInterface $transformer, - private AppOptions $appOptions, + private readonly ClientInterface $httpClient, + private readonly EntityManagerInterface $em, + private readonly LoggerInterface $logger, + private readonly WebhookOptions $webhookOptions, + private readonly DataTransformerInterface $transformer, + private readonly AppOptions $appOptions, ) { } @@ -82,11 +82,11 @@ private function buildRequestOptions(Visit $visit): array */ private function performRequests(array $requestOptions, string $visitId): array { - return map( - $this->webhookOptions->webhooks(), + return array_map( fn (string $webhook): PromiseInterface => $this->httpClient ->requestAsync(RequestMethodInterface::METHOD_POST, $webhook, $requestOptions) ->otherwise(fn (Throwable $e) => $this->logWebhookFailure($webhook, $visitId, $e)), + $this->webhookOptions->webhooks(), ); } diff --git a/module/Core/src/Importer/ShortUrlImporting.php b/module/Core/src/Importer/ShortUrlImporting.php index f806f856a..ad812e8c8 100644 --- a/module/Core/src/Importer/ShortUrlImporting.php +++ b/module/Core/src/Importer/ShortUrlImporting.php @@ -12,9 +12,9 @@ use function Shlinkio\Shlink\Core\normalizeDate; use function sprintf; -final class ShortUrlImporting +final readonly class ShortUrlImporting { - private function __construct(private readonly ShortUrl $shortUrl, private readonly bool $isNew) + private function __construct(private ShortUrl $shortUrl, private bool $isNew) { } @@ -57,11 +57,18 @@ public function importVisits(iterable $visits, EntityManagerInterface $em): stri private function resolveShortUrl(EntityManagerInterface $em): ShortUrl { + // If wrapped ShortUrl has no ID, avoid trying to query the EM, as it would fail in Postgres. + // See https://github.com/shlinkio/shlink/issues/1947 + $id = $this->shortUrl->getId(); + if (!$id) { + return $this->shortUrl; + } + // Instead of directly accessing wrapped ShortUrl entity, try to get it from the EM. // With this, we will get the same entity from memory if it is known by the EM, but if it was cleared, the EM // will fetch it again from the database, preventing errors at runtime. // However, if the EM was not flushed yet, the entity will not be found by ID, but it is known by the EM. // In that case, we fall back to wrapped ShortUrl entity directly. - return $em->find(ShortUrl::class, $this->shortUrl->getId()) ?? $this->shortUrl; + return $em->find(ShortUrl::class, $id) ?? $this->shortUrl; } } diff --git a/module/Core/src/ShortUrl/Entity/ShortUrl.php b/module/Core/src/ShortUrl/Entity/ShortUrl.php index 8fbec5ed5..e53e9afaf 100644 --- a/module/Core/src/ShortUrl/Entity/ShortUrl.php +++ b/module/Core/src/ShortUrl/Entity/ShortUrl.php @@ -27,8 +27,8 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; use function array_fill_keys; +use function array_map; use function count; -use function Functional\map; use function Shlinkio\Shlink\Core\enumValues; use function Shlinkio\Shlink\Core\generateRandomShortCode; use function Shlinkio\Shlink\Core\normalizeDate; @@ -90,9 +90,9 @@ public static function create( $instance->longUrl = $creation->getLongUrl(); $instance->dateCreated = Chronos::now(); $instance->visits = new ArrayCollection(); - $instance->deviceLongUrls = new ArrayCollection(map( - $creation->deviceLongUrls, + $instance->deviceLongUrls = new ArrayCollection(array_map( fn (DeviceLongUrlPair $pair) => DeviceLongUrl::fromShortUrlAndPair($instance, $pair), + $creation->deviceLongUrls, )); $instance->tags = $relationResolver->resolveTags($creation->tags); $instance->validSince = $creation->validSince; diff --git a/module/Core/src/ShortUrl/Model/DeviceLongUrlPair.php b/module/Core/src/ShortUrl/Model/DeviceLongUrlPair.php index d017c7e5d..a48c666bd 100644 --- a/module/Core/src/ShortUrl/Model/DeviceLongUrlPair.php +++ b/module/Core/src/ShortUrl/Model/DeviceLongUrlPair.php @@ -6,9 +6,6 @@ use Shlinkio\Shlink\Core\Model\DeviceType; -use function array_values; -use function Functional\group; -use function Functional\map; use function trim; final class DeviceLongUrlPair @@ -27,20 +24,21 @@ public static function fromRawTypeAndLongUrl(string $type, string $longUrl): sel * * The first one is a list of mapped instances for those entries in the map with non-null value * * The second is a list of DeviceTypes which have been provided with value null * - * @param array $map + * @param array $map * @return array{array, DeviceType[]} */ public static function fromMapToChangeSet(array $map): array { - $typesWithNullUrl = group($map, static fn (?string $longUrl) => $longUrl === null ? 'remove' : 'keep'); - $deviceTypesToRemove = array_values(map( - $typesWithNullUrl['remove'] ?? [], - static fn ($_, string $deviceType) => DeviceType::from($deviceType), - )); - $pairsToKeep = map( - $typesWithNullUrl['keep'] ?? [], - fn (string $longUrl, string $deviceType) => self::fromRawTypeAndLongUrl($deviceType, $longUrl), - ); + $pairsToKeep = []; + $deviceTypesToRemove = []; + + foreach ($map as $deviceType => $longUrl) { + if ($longUrl === null) { + $deviceTypesToRemove[] = DeviceType::from($deviceType); + } else { + $pairsToKeep[$deviceType] = self::fromRawTypeAndLongUrl($deviceType, $longUrl); + } + } return [$pairsToKeep, $deviceTypesToRemove]; } diff --git a/module/Core/src/ShortUrl/Model/OrderableField.php b/module/Core/src/ShortUrl/Model/OrderableField.php index ac1bc632f..685f6f12a 100644 --- a/module/Core/src/ShortUrl/Model/OrderableField.php +++ b/module/Core/src/ShortUrl/Model/OrderableField.php @@ -2,7 +2,7 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Model; -use function Functional\contains; +use function Shlinkio\Shlink\Core\ArrayUtils\contains; enum OrderableField: string { @@ -16,8 +16,8 @@ enum OrderableField: string public static function isBasicField(string $value): bool { return contains( - [self::LONG_URL->value, self::SHORT_CODE->value, self::DATE_CREATED->value, self::TITLE->value], $value, + [self::LONG_URL->value, self::SHORT_CODE->value, self::DATE_CREATED->value, self::TITLE->value], ); } diff --git a/module/Core/src/ShortUrl/Model/Validation/DeviceLongUrlsValidator.php b/module/Core/src/ShortUrl/Model/Validation/DeviceLongUrlsValidator.php index 9fda18098..82119e4e9 100644 --- a/module/Core/src/ShortUrl/Model/Validation/DeviceLongUrlsValidator.php +++ b/module/Core/src/ShortUrl/Model/Validation/DeviceLongUrlsValidator.php @@ -10,9 +10,9 @@ use function array_keys; use function array_values; -use function Functional\contains; -use function Functional\every; use function is_array; +use function Shlinkio\Shlink\Core\ArrayUtils\contains; +use function Shlinkio\Shlink\Core\ArrayUtils\every; use function Shlinkio\Shlink\Core\enumValues; class DeviceLongUrlsValidator extends AbstractValidator @@ -41,7 +41,7 @@ public function isValid(mixed $value): bool $validValues = enumValues(DeviceType::class); $keys = array_keys($value); - if (! every($keys, static fn ($key) => contains($validValues, $key))) { + if (! every($keys, static fn ($key) => contains($key, $validValues))) { $this->error(self::INVALID_DEVICE); return false; } diff --git a/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php b/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php index 17669f322..6c49ab5f9 100644 --- a/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php +++ b/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php @@ -15,9 +15,8 @@ use Symfony\Component\Lock\LockFactory; use Symfony\Component\Lock\Store\InMemoryStore; -use function Functional\invoke; -use function Functional\map; -use function Functional\unique; +use function array_map; +use function array_unique; class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInterface { @@ -74,10 +73,10 @@ public function resolveTags(array $tags): Collections\Collection return new Collections\ArrayCollection(); } - $tags = unique($tags); + $tags = array_unique($tags); $repo = $this->em->getRepository(Tag::class); - return new Collections\ArrayCollection(map($tags, function (string $tagName) use ($repo): Tag { + return new Collections\ArrayCollection(array_map(function (string $tagName) use ($repo): Tag { $this->lock($this->tagLocks, 'tag_' . $tagName); $existingTag = $repo->findOneBy(['name' => $tagName]); @@ -91,7 +90,7 @@ public function resolveTags(array $tags): Collections\Collection $this->em->persist($tag); return $tag; - })); + }, $tags)); } private function memoizeNewTag(string $tagName): Tag @@ -110,6 +109,7 @@ private function lock(array &$locks, string $name): void $lock->acquire(true); } + /** /** * @param array $locks */ @@ -126,9 +126,15 @@ public function postFlush(): void $this->memoizedNewTags = []; // Release all locks - invoke($this->tagLocks, 'release'); - invoke($this->domainLocks, 'release'); - $this->tagLocks = []; - $this->domainLocks = []; + $this->releaseLocks($this->tagLocks); + $this->releaseLocks($this->domainLocks); + } + + private function releaseLocks(array &$locks): void + { + foreach ($locks as $tagLock) { + $tagLock->release(); + } + $locks = []; } } diff --git a/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php b/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php index 609a300c2..c1a9d0ab3 100644 --- a/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php +++ b/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php @@ -8,7 +8,7 @@ use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\Tag\Entity\Tag; -use function Functional\map; +use function array_map; class SimpleShortUrlRelationResolver implements ShortUrlRelationResolverInterface { @@ -23,6 +23,6 @@ public function resolveDomain(?string $domain): ?Domain */ public function resolveTags(array $tags): Collections\Collection { - return new Collections\ArrayCollection(map($tags, fn (string $tag) => new Tag($tag))); + return new Collections\ArrayCollection(array_map(fn (string $tag) => new Tag($tag), $tags)); } } diff --git a/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php b/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php index 9de5c4084..a66419984 100644 --- a/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php +++ b/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php @@ -7,10 +7,10 @@ use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; +use Shlinkio\Shlink\Core\Tag\Entity\Tag; use Shlinkio\Shlink\Core\Visit\Model\VisitsSummary; -use function Functional\invoke; -use function Functional\invoke_if; +use function array_map; class ShortUrlDataTransformer implements DataTransformerInterface { @@ -29,7 +29,7 @@ public function transform($shortUrl): array // phpcs:ignore 'longUrl' => $shortUrl->getLongUrl(), 'deviceLongUrls' => $shortUrl->deviceLongUrls(), 'dateCreated' => $shortUrl->getDateCreated()->toAtomString(), - 'tags' => invoke($shortUrl->getTags(), '__toString'), + 'tags' => array_map(static fn (Tag $tag) => $tag->__toString(), $shortUrl->getTags()->toArray()), 'meta' => $this->buildMeta($shortUrl), 'domain' => $shortUrl->getDomain(), 'title' => $shortUrl->title(), @@ -52,8 +52,8 @@ private function buildMeta(ShortUrl $shortUrl): array $maxVisits = $shortUrl->getMaxVisits(); return [ - 'validSince' => invoke_if($validSince, 'toAtomString'), - 'validUntil' => invoke_if($validUntil, 'toAtomString'), + 'validSince' => $validSince?->toAtomString(), + 'validUntil' => $validUntil?->toAtomString(), 'maxVisits' => $maxVisits, ]; } diff --git a/module/Core/src/Tag/Repository/TagRepository.php b/module/Core/src/Tag/Repository/TagRepository.php index 278dbe8b5..ce8b1f765 100644 --- a/module/Core/src/Tag/Repository/TagRepository.php +++ b/module/Core/src/Tag/Repository/TagRepository.php @@ -17,8 +17,8 @@ use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin; use Shlinkio\Shlink\Rest\Entity\ApiKey; -use function Functional\each; -use function Functional\map; +use function array_map; +use function array_walk; use function Shlinkio\Shlink\Core\camelCaseToSnakeCase; use const PHP_INT_MAX; @@ -95,7 +95,8 @@ public function findTagsWithInfo(?TagsListFiltering $filtering = null): array $nonBotVisitsSubQb = $buildVisitsSubQb(true, 'non_bot_visits'); // Apply API key specification to all sub-queries - each([$tagsSubQb, $allVisitsSubQb, $nonBotVisitsSubQb], $applyApiKeyToNativeQb); + $queryBuilders = [$tagsSubQb, $allVisitsSubQb, $nonBotVisitsSubQb]; + array_walk($queryBuilders, $applyApiKeyToNativeQb); // A native query builder needs to be used here, because DQL and ORM query builders do not support // sub-queries at "from" and "join" level. @@ -126,9 +127,9 @@ public function findTagsWithInfo(?TagsListFiltering $filtering = null): array $rsm->addScalarResult('non_bot_visits', 'nonBotVisits'); $rsm->addScalarResult('short_urls_count', 'shortUrlsCount'); - return map( - $this->getEntityManager()->createNativeQuery($mainQb->getSQL(), $rsm)->getResult(), + return array_map( TagInfo::fromRawData(...), + $this->getEntityManager()->createNativeQuery($mainQb->getSQL(), $rsm)->getResult(), ); } diff --git a/module/Core/src/Util/RedirectStatus.php b/module/Core/src/Util/RedirectStatus.php index 76c047f4a..f561e2125 100644 --- a/module/Core/src/Util/RedirectStatus.php +++ b/module/Core/src/Util/RedirectStatus.php @@ -2,7 +2,7 @@ namespace Shlinkio\Shlink\Core\Util; -use function Functional\contains; +use function Shlinkio\Shlink\Core\ArrayUtils\contains; enum RedirectStatus: int { @@ -13,11 +13,11 @@ enum RedirectStatus: int public function allowsCache(): bool { - return contains([self::STATUS_301, self::STATUS_308], $this); + return contains($this, [self::STATUS_301, self::STATUS_308]); } public function isLegacyStatus(): bool { - return contains([self::STATUS_301, self::STATUS_302], $this); + return contains($this, [self::STATUS_301, self::STATUS_302]); } } diff --git a/module/Core/src/Visit/RequestTracker.php b/module/Core/src/Visit/RequestTracker.php index cb43e10dd..1a6b04f99 100644 --- a/module/Core/src/Visit/RequestTracker.php +++ b/module/Core/src/Visit/RequestTracker.php @@ -16,10 +16,11 @@ use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\Visit\Model\Visitor; +use function array_keys; +use function array_map; use function explode; -use function Functional\map; -use function Functional\some; use function implode; +use function Shlinkio\Shlink\Core\ArrayUtils\some; use function str_contains; class RequestTracker implements RequestTrackerInterface, RequestMethodInterface @@ -96,11 +97,15 @@ private function shouldDisableTrackingFromAddress(?string $remoteAddr): bool private function parseValueWithWildcards(string $value, array $remoteAddrParts): ?RangeInterface { + $octets = explode('.', $value); + $keys = array_keys($octets); + // Replace wildcard parts with the corresponding ones from the remote address return Factory::parseRangeString( - implode('.', map( - explode('.', $value), + implode('.', array_map( fn (string $part, int $index) => $part === '*' ? $remoteAddrParts[$index] : $part, + $octets, + $keys, )), ); } diff --git a/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php b/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php index 46c08d25f..b359e35d5 100644 --- a/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php +++ b/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php @@ -22,8 +22,8 @@ use Shlinkio\Shlink\Core\Visit\Model\Visitor; use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase; +use function array_map; use function count; -use function Functional\map; use function range; class ShortUrlListRepositoryTest extends DatabaseTestCase @@ -60,22 +60,22 @@ public function findListProperlyFiltersResult(): void $this->getEntityManager()->persist($foo); $bar = ShortUrl::withLongUrl('https://bar'); - $visits = map(range(0, 5), function () use ($bar) { + $visits = array_map(function () use ($bar) { $visit = Visit::forValidShortUrl($bar, Visitor::botInstance()); $this->getEntityManager()->persist($visit); return $visit; - }); + }, range(0, 5)); $bar->setVisits(new ArrayCollection($visits)); $this->getEntityManager()->persist($bar); $foo2 = ShortUrl::withLongUrl('https://foo_2'); - $visits2 = map(range(0, 3), function () use ($foo2) { + $visits2 = array_map(function () use ($foo2) { $visit = Visit::forValidShortUrl($foo2, Visitor::emptyInstance()); $this->getEntityManager()->persist($visit); return $visit; - }); + }, range(0, 3)); $foo2->setVisits(new ArrayCollection($visits2)); $ref = new ReflectionObject($foo2); $dateProp = $ref->getProperty('dateCreated'); diff --git a/module/Core/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php b/module/Core/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php index 0dd833418..f88a8e7f5 100644 --- a/module/Core/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php +++ b/module/Core/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php @@ -12,7 +12,7 @@ use Shlinkio\Shlink\Core\Tag\Repository\TagRepository; use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase; -use function Functional\map; +use function array_map; class TagsPaginatorAdapterTest extends DatabaseTestCase { @@ -47,7 +47,7 @@ public function expectedListOfTagsIsReturned( 'orderBy' => $orderBy, ]), null); - $tagNames = map($adapter->getSlice($offset, $length), static fn (Tag $tag) => $tag->__toString()); + $tagNames = array_map(static fn (Tag $tag) => $tag->__toString(), [...$adapter->getSlice($offset, $length)]); self::assertEquals($expectedTags, $tagNames); self::assertEquals($expectedTotalCount, $adapter->getNbResults()); diff --git a/module/Core/test-db/Visit/Repository/VisitLocationRepositoryTest.php b/module/Core/test-db/Visit/Repository/VisitLocationRepositoryTest.php index 79c80a24f..c5aadf1f6 100644 --- a/module/Core/test-db/Visit/Repository/VisitLocationRepositoryTest.php +++ b/module/Core/test-db/Visit/Repository/VisitLocationRepositoryTest.php @@ -14,7 +14,7 @@ use Shlinkio\Shlink\IpGeolocation\Model\Location; use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase; -use function Functional\map; +use function array_map; use function range; class VisitLocationRepositoryTest extends DatabaseTestCase @@ -57,6 +57,6 @@ public function findVisitsReturnsProperVisits(int $blockSize): void public static function provideBlockSize(): iterable { - return map(range(1, 10), fn (int $value) => [$value]); + return array_map(static fn (int $value) => [$value], range(1, 10)); } } diff --git a/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php b/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php index f85a9d440..8b9c10aca 100644 --- a/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php +++ b/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php @@ -28,7 +28,7 @@ use Shlinkio\Shlink\Core\Visit\Model\Visitor; use function count; -use function Functional\contains; +use function Shlinkio\Shlink\Core\ArrayUtils\contains; class NotifyVisitToWebHooksTest extends TestCase { @@ -102,7 +102,7 @@ public function expectedRequestsArePerformedToWebhooks(Visit $visit, array $expe return true; }), )->willReturnCallback(function ($_, $webhook) use ($invalidWebhooks) { - $shouldReject = contains($invalidWebhooks, $webhook); + $shouldReject = contains($webhook, $invalidWebhooks); return $shouldReject ? new RejectedPromise(new Exception('')) : new FulfilledPromise(''); }); $this->logger->expects($this->exactly(count($invalidWebhooks)))->method('warning')->with( diff --git a/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php b/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php index 0002d3b13..e722bf253 100644 --- a/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php +++ b/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php @@ -27,9 +27,8 @@ use Shlinkio\Shlink\Core\Visit\Transformer\OrphanVisitDataTransformer; use Throwable; +use function array_walk; use function count; -use function Functional\each; -use function Functional\noop; class NotifyVisitToRabbitMqTest extends TestCase { @@ -77,7 +76,7 @@ public function expectedChannelsAreNotifiedBasedOnTheVisitType(Visit $visit, arr { $visitId = '123'; $this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn($visit); - each($expectedChannels, function (string $method): void { + array_walk($expectedChannels, function (string $method): void { $this->updatesGenerator->expects($this->once())->method($method)->with( $this->isInstanceOf(Visit::class), )->willReturn(Update::forTopicAndPayload('', [])); @@ -153,7 +152,7 @@ public static function provideLegacyPayloads(): iterable yield 'legacy non-orphan visit' => [ true, $visit = Visit::forValidShortUrl(ShortUrl::withLongUrl('https://longUrl'), Visitor::emptyInstance()), - noop(...), + static fn () => null, function (MockObject & PublishingHelperInterface $helper) use ($visit): void { $helper->method('publishUpdate')->with(self::callback(function (Update $update) use ($visit): bool { $payload = $update->payload; @@ -170,7 +169,7 @@ function (MockObject & PublishingHelperInterface $helper) use ($visit): void { yield 'legacy orphan visit' => [ true, Visit::forBasePath(Visitor::emptyInstance()), - noop(...), + static fn () => null, function (MockObject & PublishingHelperInterface $helper): void { $helper->method('publishUpdate')->with(self::callback(function (Update $update): bool { $payload = $update->payload; diff --git a/module/Core/test/EventDispatcher/UpdateGeoLiteDbTest.php b/module/Core/test/EventDispatcher/UpdateGeoLiteDbTest.php index 6ba20ec87..dc6045218 100644 --- a/module/Core/test/EventDispatcher/UpdateGeoLiteDbTest.php +++ b/module/Core/test/EventDispatcher/UpdateGeoLiteDbTest.php @@ -16,7 +16,7 @@ use Shlinkio\Shlink\Core\EventDispatcher\Event\GeoLiteDbCreated; use Shlinkio\Shlink\Core\EventDispatcher\UpdateGeoLiteDb; -use function Functional\map; +use function array_map; class UpdateGeoLiteDbTest extends TestCase { @@ -124,9 +124,9 @@ public function dispatchesEventOnlyWhenDbFileHasBeenCreatedForTheFirstTime( public static function provideGeolocationResults(): iterable { - return map(GeolocationResult::cases(), static fn (GeolocationResult $value) => [ + return array_map(static fn (GeolocationResult $value) => [ $value, $value === GeolocationResult::DB_CREATED ? 1 : 0, - ]); + ], GeolocationResult::cases()); } } diff --git a/module/Core/test/Exception/DeleteShortUrlExceptionTest.php b/module/Core/test/Exception/DeleteShortUrlExceptionTest.php index 8d82c11ee..c1b2bcecd 100644 --- a/module/Core/test/Exception/DeleteShortUrlExceptionTest.php +++ b/module/Core/test/Exception/DeleteShortUrlExceptionTest.php @@ -10,7 +10,7 @@ use Shlinkio\Shlink\Core\Exception\DeleteShortUrlException; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; -use function Functional\map; +use function array_map; use function range; use function Shlinkio\Shlink\Core\generateRandomShortCode; use function sprintf; @@ -42,13 +42,13 @@ public function fromVisitsThresholdGeneratesMessageProperly( public static function provideThresholds(): array { - return map(range(5, 50, 5), function (int $number) { + return array_map(function (int $number) { return [$number, $shortCode = generateRandomShortCode(6), sprintf( 'Impossible to delete short URL with short code "%s", since it has more than "%s" visits.', $shortCode, $number, )]; - }); + }, range(5, 50, 5)); } #[Test] diff --git a/module/Core/test/Functions/FunctionsTest.php b/module/Core/test/Functions/FunctionsTest.php index 3f6026a0e..715685af7 100644 --- a/module/Core/test/Functions/FunctionsTest.php +++ b/module/Core/test/Functions/FunctionsTest.php @@ -13,7 +13,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\OrderableField; use Shlinkio\Shlink\Core\Visit\Model\VisitType; -use function Functional\map; +use function array_map; use function Shlinkio\Shlink\Core\enumValues; class FunctionsTest extends TestCase @@ -29,18 +29,21 @@ public function enumValuesReturnsExpectedValueForEnum(string $enum, array $expec public static function provideEnums(): iterable { - yield EnvVars::class => [EnvVars::class, map(EnvVars::cases(), static fn (EnvVars $envVar) => $envVar->value)]; + yield EnvVars::class => [ + EnvVars::class, + array_map(static fn (EnvVars $envVar) => $envVar->value, EnvVars::cases()), + ]; yield VisitType::class => [ VisitType::class, - map(VisitType::cases(), static fn (VisitType $envVar) => $envVar->value), + array_map(static fn (VisitType $envVar) => $envVar->value, VisitType::cases()), ]; yield DeviceType::class => [ DeviceType::class, - map(DeviceType::cases(), static fn (DeviceType $envVar) => $envVar->value), + array_map(static fn (DeviceType $envVar) => $envVar->value, DeviceType::cases()), ]; yield OrderableField::class => [ OrderableField::class, - map(OrderableField::cases(), static fn (OrderableField $envVar) => $envVar->value), + array_map(static fn (OrderableField $envVar) => $envVar->value, OrderableField::cases()), ]; } } diff --git a/module/Core/test/Importer/ImportedLinksProcessorTest.php b/module/Core/test/Importer/ImportedLinksProcessorTest.php index bf2896e2c..e267744d1 100644 --- a/module/Core/test/Importer/ImportedLinksProcessorTest.php +++ b/module/Core/test/Importer/ImportedLinksProcessorTest.php @@ -32,8 +32,8 @@ use Symfony\Component\Console\Style\StyleInterface; use function count; -use function Functional\contains; -use function Functional\some; +use function Shlinkio\Shlink\Core\ArrayUtils\contains; +use function Shlinkio\Shlink\Core\ArrayUtils\some; use function sprintf; use function str_contains; @@ -128,8 +128,8 @@ public function alreadyImportedUrlsAreSkipped(): void $this->em->method('getRepository')->with(ShortUrl::class)->willReturn($this->repo); $this->repo->expects($this->exactly(count($urls)))->method('findOneByImportedUrl')->willReturnCallback( fn (ImportedShlinkUrl $url): ?ShortUrl => contains( - ['https://foo', 'https://baz2', 'https://baz3'], $url->longUrl, + ['https://foo', 'https://baz2', 'https://baz3'], ) ? ShortUrl::fromImport($url, true) : null, ); $this->shortCodeHelper->expects($this->exactly(2))->method('ensureShortCodeUniqueness')->willReturn(true); @@ -232,15 +232,20 @@ public static function provideUrlsWithVisits(): iterable } #[Test, DataProvider('provideFoundShortUrls')] - public function visitsArePersistedWithProperShortUrl(?ShortUrl $foundShortUrl): void + public function visitsArePersistedWithProperShortUrl(ShortUrl $originalShortUrl, ?ShortUrl $foundShortUrl): void { - $originalShortUrl = ShortUrl::withLongUrl('https://foo'); - $this->em->method('getRepository')->with(ShortUrl::class)->willReturn($this->repo); $this->repo->expects($this->once())->method('findOneByImportedUrl')->willReturn($originalShortUrl); - $this->em->expects($this->exactly(2))->method('find')->willReturn($foundShortUrl); + if (!$originalShortUrl->getId()) { + $this->em->expects($this->never())->method('find'); + } else { + $this->em->expects($this->exactly(2))->method('find')->willReturn($foundShortUrl); + } $this->em->expects($this->once())->method('persist')->willReturnCallback( - static fn (Visit $visit) => Assert::assertSame($foundShortUrl ?? $originalShortUrl, $visit->getShortUrl()), + static fn (Visit $visit) => Assert::assertSame( + $foundShortUrl ?? $originalShortUrl, + $visit->getShortUrl(), + ), ); $now = Chronos::now(); @@ -253,8 +258,12 @@ public function visitsArePersistedWithProperShortUrl(?ShortUrl $foundShortUrl): public static function provideFoundShortUrls(): iterable { - yield [null]; - yield [ShortUrl::withLongUrl('https://bar')]; + yield 'not found new URL' => [ShortUrl::withLongUrl('https://foo')->setId('123'), null]; + yield 'found new URL' => [ + ShortUrl::withLongUrl('https://foo')->setId('123'), + ShortUrl::withLongUrl('https://bar'), + ]; + yield 'old URL without ID' => [$originalShortUrl = ShortUrl::withLongUrl('https://foo'), $originalShortUrl]; } /** diff --git a/module/Core/test/ShortUrl/DeleteShortUrlServiceTest.php b/module/Core/test/ShortUrl/DeleteShortUrlServiceTest.php index 65351a931..3ac9897c2 100644 --- a/module/Core/test/ShortUrl/DeleteShortUrlServiceTest.php +++ b/module/Core/test/ShortUrl/DeleteShortUrlServiceTest.php @@ -18,7 +18,7 @@ use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\Visitor; -use function Functional\map; +use function array_map; use function range; use function sprintf; @@ -31,7 +31,7 @@ class DeleteShortUrlServiceTest extends TestCase protected function setUp(): void { $shortUrl = ShortUrl::createFake()->setVisits(new ArrayCollection( - map(range(0, 10), fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance())), + array_map(fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance()), range(0, 10)), )); $this->shortCode = $shortUrl->getShortCode(); diff --git a/module/Core/test/ShortUrl/Entity/ShortUrlTest.php b/module/Core/test/ShortUrl/Entity/ShortUrlTest.php index bd83fd9a5..0a898399c 100644 --- a/module/Core/test/ShortUrl/Entity/ShortUrlTest.php +++ b/module/Core/test/ShortUrl/Entity/ShortUrlTest.php @@ -19,9 +19,9 @@ use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; use Shlinkio\Shlink\Importer\Sources\ImportSource; -use function Functional\every; -use function Functional\map; +use function array_map; use function range; +use function Shlinkio\Shlink\Core\ArrayUtils\every; use function strlen; use function strtolower; @@ -88,7 +88,7 @@ public function shortCodesHaveExpectedLength(?int $length, int $expectedLength): public static function provideLengths(): iterable { yield [null, DEFAULT_SHORT_CODES_LENGTH]; - yield from map(range(4, 10), fn (int $value) => [$value, $value]); + yield from array_map(fn (int $value) => [$value, $value], range(4, 10)); } #[Test] diff --git a/module/Core/test/ShortUrl/Middleware/TrimTrailingSlashMiddlewareTest.php b/module/Core/test/ShortUrl/Middleware/TrimTrailingSlashMiddlewareTest.php index b43eed91d..b05ab7d9e 100644 --- a/module/Core/test/ShortUrl/Middleware/TrimTrailingSlashMiddlewareTest.php +++ b/module/Core/test/ShortUrl/Middleware/TrimTrailingSlashMiddlewareTest.php @@ -16,9 +16,6 @@ use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\ShortUrl\Middleware\TrimTrailingSlashMiddleware; -use function Functional\compose; -use function Functional\const_function; - class TrimTrailingSlashMiddlewareTest extends TestCase { private MockObject & RequestHandlerInterface $requestHandler; @@ -34,7 +31,10 @@ public function returnsExpectedResponse( ServerRequestInterface $inputRequest, callable $assertions, ): void { - $arg = compose($assertions, const_function(true)); + $arg = static function (...$args) use ($assertions): bool { + $assertions(...$args); + return true; + }; $this->requestHandler->expects($this->once())->method('handle')->with($this->callback($arg))->willReturn( new Response(), ); diff --git a/module/Core/test/ShortUrl/ShortUrlResolverTest.php b/module/Core/test/ShortUrl/ShortUrlResolverTest.php index 4057691b3..a95426ba1 100644 --- a/module/Core/test/ShortUrl/ShortUrlResolverTest.php +++ b/module/Core/test/ShortUrl/ShortUrlResolverTest.php @@ -25,7 +25,7 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; use ShlinkioTest\Shlink\Core\Util\ApiKeyDataProviders; -use function Functional\map; +use function array_map; use function range; class ShortUrlResolverTest extends TestCase @@ -113,9 +113,9 @@ public static function provideDisabledShortUrls(): iterable $shortUrl = ShortUrl::create( ShortUrlCreation::fromRawData(['maxVisits' => 3, 'longUrl' => 'https://longUrl']), ); - $shortUrl->setVisits(new ArrayCollection(map( - range(0, 4), + $shortUrl->setVisits(new ArrayCollection(array_map( fn () => Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()), + range(0, 4), ))); return $shortUrl; @@ -132,9 +132,9 @@ public static function provideDisabledShortUrls(): iterable 'validUntil' => $now->subMonths(1)->toAtomString(), 'longUrl' => 'https://longUrl', ])); - $shortUrl->setVisits(new ArrayCollection(map( - range(0, 4), + $shortUrl->setVisits(new ArrayCollection(array_map( fn () => Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()), + range(0, 4), ))); return $shortUrl; diff --git a/module/Core/test/ShortUrl/Transformer/ShortUrlDataTransformerTest.php b/module/Core/test/ShortUrl/Transformer/ShortUrlDataTransformerTest.php index 27916063e..349392dbd 100644 --- a/module/Core/test/ShortUrl/Transformer/ShortUrlDataTransformerTest.php +++ b/module/Core/test/ShortUrl/Transformer/ShortUrlDataTransformerTest.php @@ -84,4 +84,14 @@ public static function provideShortUrls(): iterable ], ]; } + + #[Test] + public function properTagsAreReturned(): void + { + ['tags' => $tags] = $this->transformer->transform(ShortUrl::create(ShortUrlCreation::fromRawData([ + 'longUrl' => 'https://longUrl', + 'tags' => ['foo', 'bar', 'baz'], + ]))); + self::assertEquals(['foo', 'bar', 'baz'], $tags); + } } diff --git a/module/Core/test/Visit/Geolocation/VisitLocatorTest.php b/module/Core/test/Visit/Geolocation/VisitLocatorTest.php index 70fc62431..1d3af228b 100644 --- a/module/Core/test/Visit/Geolocation/VisitLocatorTest.php +++ b/module/Core/test/Visit/Geolocation/VisitLocatorTest.php @@ -20,9 +20,9 @@ use Shlinkio\Shlink\Core\Visit\Repository\VisitLocationRepositoryInterface; use Shlinkio\Shlink\IpGeolocation\Model\Location; +use function array_map; use function count; use function floor; -use function Functional\map; use function range; use function sprintf; @@ -45,12 +45,12 @@ public function locateVisitsIteratesAndLocatesExpectedVisits( string $serviceMethodName, string $expectedRepoMethodName, ): void { - $unlocatedVisits = map( - range(1, 200), + $unlocatedVisits = array_map( fn (int $i) => Visit::forValidShortUrl( ShortUrl::withLongUrl(sprintf('https://short_code_%s', $i)), Visitor::emptyInstance(), ), + range(1, 200), ); $this->repo->expects($this->once())->method($expectedRepoMethodName)->willReturn($unlocatedVisits); diff --git a/module/Core/test/Visit/VisitsStatsHelperTest.php b/module/Core/test/Visit/VisitsStatsHelperTest.php index d43efc240..dd11fdef5 100644 --- a/module/Core/test/Visit/VisitsStatsHelperTest.php +++ b/module/Core/test/Visit/VisitsStatsHelperTest.php @@ -33,8 +33,8 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; use ShlinkioTest\Shlink\Core\Util\ApiKeyDataProviders; +use function array_map; use function count; -use function Functional\map; use function range; class VisitsStatsHelperTest extends TestCase @@ -75,8 +75,8 @@ function (VisitsCountFiltering $options) use ($expectedCount, $apiKey, &$callCou public static function provideCounts(): iterable { return [ - ...map(range(0, 50, 5), fn (int $value) => [$value, null]), - ...map(range(0, 18, 3), fn (int $value) => [$value, ApiKey::create()]), + ...array_map(fn (int $value) => [$value, null], range(0, 50, 5)), + ...array_map(fn (int $value) => [$value, ApiKey::create()], range(0, 18, 3)), ]; } @@ -90,7 +90,10 @@ public function infoReturnsVisitsForCertainShortCode(?ApiKey $apiKey): void $repo = $this->createMock(ShortUrlRepositoryInterface::class); $repo->expects($this->once())->method('shortCodeIsInUse')->with($identifier, $spec)->willReturn(true); - $list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance())); + $list = array_map( + static fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance()), + range(0, 1), + ); $repo2 = $this->createMock(VisitRepository::class); $repo2->method('findVisitsByShortCode')->with( $identifier, @@ -147,7 +150,10 @@ public function visitsForTagAreReturnedAsExpected(?ApiKey $apiKey): void $repo = $this->createMock(TagRepository::class); $repo->expects($this->once())->method('tagExists')->with($tag, $apiKey)->willReturn(true); - $list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance())); + $list = array_map( + static fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance()), + range(0, 1), + ); $repo2 = $this->createMock(VisitRepository::class); $repo2->method('findVisitsByTag')->with($tag, $this->isInstanceOf(VisitsListFiltering::class))->willReturn( $list, @@ -185,7 +191,10 @@ public function visitsForNonDefaultDomainAreReturnedAsExpected(?ApiKey $apiKey): $repo = $this->createMock(DomainRepository::class); $repo->expects($this->once())->method('domainExists')->with($domain, $apiKey)->willReturn(true); - $list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance())); + $list = array_map( + static fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance()), + range(0, 1), + ); $repo2 = $this->createMock(VisitRepository::class); $repo2->method('findVisitsByDomain')->with( $domain, @@ -212,7 +221,10 @@ public function visitsForDefaultDomainAreReturnedAsExpected(?ApiKey $apiKey): vo $repo = $this->createMock(DomainRepository::class); $repo->expects($this->never())->method('domainExists'); - $list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance())); + $list = array_map( + static fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance()), + range(0, 1), + ); $repo2 = $this->createMock(VisitRepository::class); $repo2->method('findVisitsByDomain')->with( 'DEFAULT', @@ -236,7 +248,7 @@ public function visitsForDefaultDomainAreReturnedAsExpected(?ApiKey $apiKey): vo #[Test] public function orphanVisitsAreReturnedAsExpected(): void { - $list = map(range(0, 3), fn () => Visit::forBasePath(Visitor::emptyInstance())); + $list = array_map(static fn () => Visit::forBasePath(Visitor::emptyInstance()), range(0, 3)); $repo = $this->createMock(VisitRepository::class); $repo->expects($this->once())->method('countOrphanVisits')->with( $this->isInstanceOf(VisitsCountFiltering::class), @@ -254,7 +266,10 @@ public function orphanVisitsAreReturnedAsExpected(): void #[Test] public function nonOrphanVisitsAreReturnedAsExpected(): void { - $list = map(range(0, 3), fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance())); + $list = array_map( + static fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance()), + range(0, 3), + ); $repo = $this->createMock(VisitRepository::class); $repo->expects($this->once())->method('countNonOrphanVisits')->with( $this->isInstanceOf(VisitsCountFiltering::class), diff --git a/module/Rest/config/access-logs.config.php b/module/Rest/config/access-logs.config.php index 1f0dd0e8c..def1a93a5 100644 --- a/module/Rest/config/access-logs.config.php +++ b/module/Rest/config/access-logs.config.php @@ -11,7 +11,7 @@ return [ 'access_logs' => [ - 'ignored_paths' => [ + 'ignored_path_prefixes' => [ Action\HealthAction::ROUTE_PATH, ], ], @@ -20,7 +20,7 @@ ConfigAbstractFactory::class => [ // Use MergeReplaceKey to overwrite what was defined in shlink-common, instead of merging it AccessLogMiddleware::class => new MergeReplaceKey( - [AccessLogMiddleware::LOGGER_SERVICE_NAME, 'config.access_logs.ignored_paths'], + [AccessLogMiddleware::LOGGER_SERVICE_NAME, 'config.access_logs.ignored_path_prefixes'], ), ], diff --git a/module/Rest/src/Action/Tag/ListTagsAction.php b/module/Rest/src/Action/Tag/ListTagsAction.php index 34f44475b..9674d5bc5 100644 --- a/module/Rest/src/Action/Tag/ListTagsAction.php +++ b/module/Rest/src/Action/Tag/ListTagsAction.php @@ -14,7 +14,7 @@ use Shlinkio\Shlink\Rest\Action\AbstractRestAction; use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; -use function Functional\map; +use function array_map; class ListTagsAction extends AbstractRestAction { @@ -41,7 +41,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface // This part is deprecated. To get tags with stats, the /tags/stats endpoint should be used instead $tagsInfo = $this->tagService->tagsInfo($params, $apiKey); $rawTags = $this->serializePaginator($tagsInfo, dataProp: 'stats'); - $rawTags['data'] = map($tagsInfo, static fn (TagInfo $info) => $info->tag); + $rawTags['data'] = array_map(static fn (TagInfo $info) => $info->tag, [...$tagsInfo]); return new JsonResponse(['tags' => $rawTags]); } diff --git a/module/Rest/src/ConfigProvider.php b/module/Rest/src/ConfigProvider.php index 215a4d6e5..067c69524 100644 --- a/module/Rest/src/ConfigProvider.php +++ b/module/Rest/src/ConfigProvider.php @@ -4,8 +4,9 @@ namespace Shlinkio\Shlink\Rest; -use function Functional\first; -use function Functional\map; +use function array_filter; +use function array_map; +use function reset; use function Shlinkio\Shlink\Config\loadConfigFromGlob; use function sprintf; @@ -23,19 +24,20 @@ public function __invoke(): array public static function applyRoutesPrefix(array $routes): array { $healthRoute = self::buildUnversionedHealthRouteFromExistingRoutes($routes); - $prefixedRoutes = map($routes, static function (array $route) { + $prefixedRoutes = array_map(static function (array $route) { ['path' => $path] = $route; $route['path'] = sprintf('%s%s', self::ROUTES_PREFIX, $path); return $route; - }); + }, $routes); return $healthRoute !== null ? [...$prefixedRoutes, $healthRoute] : $prefixedRoutes; } private static function buildUnversionedHealthRouteFromExistingRoutes(array $routes): ?array { - $healthRoute = first($routes, fn (array $route) => $route['path'] === '/health'); - if ($healthRoute === null) { + $healthRoutes = array_filter($routes, fn (array $route) => $route['path'] === '/health'); + $healthRoute = reset($healthRoutes); + if ($healthRoute === false) { return null; } diff --git a/module/Rest/src/Exception/BackwardsCompatibleProblemDetailsException.php b/module/Rest/src/Exception/BackwardsCompatibleProblemDetailsException.php index 685d37958..8cfb918cb 100644 --- a/module/Rest/src/Exception/BackwardsCompatibleProblemDetailsException.php +++ b/module/Rest/src/Exception/BackwardsCompatibleProblemDetailsException.php @@ -15,8 +15,8 @@ use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Exception\ValidationException; +use function end; use function explode; -use function Functional\last; /** @deprecated */ class BackwardsCompatibleProblemDetailsException extends RuntimeException implements ProblemDetailsExceptionInterface @@ -77,7 +77,9 @@ private function remapTypeInArray(array $wrappedArray): array private function remapType(string $wrappedType): string { - $lastSegment = last(explode('/', $wrappedType)); + $segments = explode('/', $wrappedType); + $lastSegment = end($segments); + return match ($lastSegment) { ValidationException::ERROR_CODE => 'INVALID_ARGUMENT', DeleteShortUrlException::ERROR_CODE => 'INVALID_SHORT_URL_DELETION', diff --git a/module/Rest/src/Middleware/AuthenticationMiddleware.php b/module/Rest/src/Middleware/AuthenticationMiddleware.php index 7b9118173..cf73ba10c 100644 --- a/module/Rest/src/Middleware/AuthenticationMiddleware.php +++ b/module/Rest/src/Middleware/AuthenticationMiddleware.php @@ -17,16 +17,16 @@ use Shlinkio\Shlink\Rest\Exception\VerifyAuthenticationException; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; -use function Functional\contains; +use function Shlinkio\Shlink\Core\ArrayUtils\contains; class AuthenticationMiddleware implements MiddlewareInterface, StatusCodeInterface, RequestMethodInterface { public const API_KEY_HEADER = 'X-Api-Key'; public function __construct( - private ApiKeyServiceInterface $apiKeyService, - private array $routesWithoutApiKey, - private array $routesWithQueryApiKey, + private readonly ApiKeyServiceInterface $apiKeyService, + private readonly array $routesWithoutApiKey, + private readonly array $routesWithQueryApiKey, ) { } @@ -38,7 +38,7 @@ public function process(Request $request, RequestHandlerInterface $handler): Res $routeResult === null || $routeResult->isFailure() || $request->getMethod() === self::METHOD_OPTIONS - || contains($this->routesWithoutApiKey, $routeResult->getMatchedRouteName()) + || contains($routeResult->getMatchedRouteName(), $this->routesWithoutApiKey) ) { return $handler->handle($request); } @@ -61,7 +61,7 @@ private function getApiKeyFromRequest(ServerRequestInterface $request, RouteResu { $routeName = $routeResult->getMatchedRouteName(); $query = $request->getQueryParams(); - $isRouteWithApiKeyInQuery = contains($this->routesWithQueryApiKey, $routeName); + $isRouteWithApiKeyInQuery = contains($routeName, $this->routesWithQueryApiKey); $apiKey = $isRouteWithApiKeyInQuery ? ($query['apiKey'] ?? '') : $request->getHeaderLine(self::API_KEY_HEADER); if (empty($apiKey)) { diff --git a/module/Rest/src/Middleware/BodyParserMiddleware.php b/module/Rest/src/Middleware/BodyParserMiddleware.php index c31bc268e..cdab82995 100644 --- a/module/Rest/src/Middleware/BodyParserMiddleware.php +++ b/module/Rest/src/Middleware/BodyParserMiddleware.php @@ -12,7 +12,7 @@ use Psr\Http\Server\RequestHandlerInterface; use Shlinkio\Shlink\Core\Exception\MalformedBodyException; -use function Functional\contains; +use function Shlinkio\Shlink\Core\ArrayUtils\contains; use function Shlinkio\Shlink\Json\json_decode; class BodyParserMiddleware implements MiddlewareInterface, RequestMethodInterface @@ -25,11 +25,11 @@ public function process(Request $request, RequestHandlerInterface $handler): Res // In requests that do not allow body or if the body has already been parsed, continue to next middleware if ( ! empty($currentParams) - || contains([ + || contains($method, [ self::METHOD_GET, self::METHOD_HEAD, self::METHOD_OPTIONS, - ], $method) + ]) ) { return $handler->handle($request); } diff --git a/module/Rest/test-api/Action/CreateShortUrlTest.php b/module/Rest/test-api/Action/CreateShortUrlTest.php index 78f738a37..01592129a 100644 --- a/module/Rest/test-api/Action/CreateShortUrlTest.php +++ b/module/Rest/test-api/Action/CreateShortUrlTest.php @@ -10,7 +10,7 @@ use PHPUnit\Framework\Attributes\Test; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; -use function Functional\map; +use function array_map; use function range; use function sprintf; @@ -108,7 +108,7 @@ public function createsNewShortUrlWithVisitsLimit(int $maxVisits): void public static function provideMaxVisits(): array { - return map(range(10, 15), fn(int $i) => [$i]); + return array_map(static fn (int $i) => [$i], range(10, 15)); } #[Test]