Merge pull request #1999 from acelaya-forks/feature/remove-deprecated-stuff

Removed deprecated features
This commit is contained in:
Alejandro Celaya 2024-02-13 22:52:00 +01:00 committed by GitHub
commit 37e0978bfc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
69 changed files with 145 additions and 1120 deletions

View file

@ -15,13 +15,6 @@ jobs:
- runtime: 'rr'
tag-suffix: 'roadrunner'
platforms: 'linux/arm64/v8,linux/amd64'
- runtime: 'openswoole'
tag-suffix: 'openswoole'
platforms: 'linux/arm/v7,linux/arm64/v8,linux/amd64'
- runtime: 'rr'
tag-suffix: 'non-root'
platforms: 'linux/arm64/v8,linux/amd64'
user-id: '1001'
uses: shlinkio/github-actions/.github/workflows/docker-build-and-publish.yml@main
secrets: inherit
with:
@ -31,4 +24,3 @@ jobs:
tags-suffix: ${{ matrix.tag-suffix }}
extra-build-args: |
SHLINK_RUNTIME=${{ matrix.runtime }}
SHLINK_USER_ID=${{ matrix.user-id && matrix.user-id || 'root' }}

View file

@ -4,10 +4,8 @@ ARG SHLINK_VERSION=latest
ENV SHLINK_VERSION ${SHLINK_VERSION}
ARG SHLINK_RUNTIME=rr
ENV SHLINK_RUNTIME ${SHLINK_RUNTIME}
ARG SHLINK_USER_ID='root'
ENV SHLINK_USER_ID ${SHLINK_USER_ID}
ENV OPENSWOOLE_VERSION 22.1.2
ENV USER_ID '1001'
ENV PDO_SQLSRV_VERSION 5.12.0
ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486'
ENV MS_ODBC_SQL_VERSION 18_18.1.1.1
@ -26,13 +24,8 @@ RUN \
apk del .dev-deps && \
apk add --no-cache postgresql icu libzip libpng
# Install openswoole and sqlsrv driver for x86_64 builds
# Install sqlsrv driver for x86_64 builds
RUN apk add --no-cache --virtual .phpize-deps ${PHPIZE_DEPS} unixodbc-dev && \
if [ "$SHLINK_RUNTIME" == 'openswoole' ]; then \
# Openswoole is deprecated. Remove in v4.0.0
pecl install openswoole-${OPENSWOOLE_VERSION} && \
docker-php-ext-enable openswoole ; \
fi; \
if [ $(uname -m) == "x86_64" ]; then \
wget https://download.microsoft.com/download/${MS_ODBC_DOWNLOAD}/msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --allow-untrusted msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
@ -47,14 +40,7 @@ FROM base as builder
COPY . .
COPY --from=composer:2 /usr/bin/composer ./composer.phar
RUN apk add --no-cache git && \
# FIXME Ignoring ext-openswoole platform req, as it makes install fail with roadrunner, even though it's a dev dependency and we are passing --no-dev
php composer.phar install --no-dev --prefer-dist --optimize-autoloader --no-progress --no-interaction --ignore-platform-req=ext-openswoole && \
if [ "$SHLINK_RUNTIME" == 'openswoole' ]; then \
# Openswoole is deprecated. Remove in v4.0.0
php composer.phar remove spiral/roadrunner spiral/roadrunner-jobs spiral/roadrunner-cli spiral/roadrunner-http --with-all-dependencies --update-no-dev --optimize-autoloader --no-progress --no-interaction ; \
elif [ "$SHLINK_RUNTIME" == 'rr' ]; then \
php composer.phar remove mezzio/mezzio-swoole --with-all-dependencies --update-no-dev --optimize-autoloader --no-progress --no-interaction --ignore-platform-req=ext-openswoole ; \
fi; \
php composer.phar clear-cache && \
rm -r docker composer.* && \
sed -i "s/%SHLINK_VERSION%/${SHLINK_VERSION}/g" config/autoload/app_options.global.php
@ -64,7 +50,7 @@ RUN apk add --no-cache git && \
FROM base
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
COPY --from=builder --chown=${SHLINK_USER_ID} /etc/shlink .
COPY --from=builder --chown=${USER_ID} /etc/shlink .
RUN ln -s /etc/shlink/bin/cli /usr/local/bin/shlink && \
if [ "$SHLINK_RUNTIME" == 'rr' ]; then \
php ./vendor/bin/rr get --no-interaction --no-config --location bin/ && chmod +x bin/rr ; \
@ -78,6 +64,6 @@ COPY docker/docker-entrypoint.sh docker-entrypoint.sh
COPY docker/config/shlink_in_docker.local.php config/autoload/shlink_in_docker.local.php
COPY docker/config/php.ini ${PHP_INI_DIR}/conf.d/
USER ${SHLINK_USER_ID}
USER ${USER_ID}
ENTRYPOINT ["/bin/sh", "./docker-entrypoint.sh"]

View file

@ -1,5 +1,50 @@
# Upgrading
## From v3.x to v4.x
### General
* Swoole and Openswoole are no longer officially supported runtimes. The recommended alternative is RoadRunner.
* Dist files for swoole/openswoole are no longer published.
* Webhooks are no longer supported. Migrate to one of the other [real-time updates](https://shlink.io/documentation/advanced/real-time-updates/) mechanisms.
### Changes in URL shortener
* The short URLs `loosely` mode is no longer supported, as it was a typo. Use `loose` mode instead.
* QR codes URLs now work by default, even for short URLs that cannot be visited due to max visits or date range limitations.
If you want to keep previous behavior, pass `QR_CODE_FOR_DISABLED_SHORT_URLS=false` or the equivalent configuration option.
### Changes in REST API
* REST API v1/v2 now behave like v3. This only affects error codes, which are now proper URIs.
* `INVALID_ARGUMENT` -> `https://shlink.io/api/error/invalid-data`
* `INVALID_SHORT_URL_DELETION` -> `https://shlink.io/api/error/invalid-short-url-deletion`
* `DOMAIN_NOT_FOUND` -> `https://shlink.io/api/error/domain-not-found`
* `FORBIDDEN_OPERATION` -> `https://shlink.io/api/error/forbidden-tag-operation`
* `INVALID_URL` -> `https://shlink.io/api/error/invalid-url`
* `INVALID_SLUG` -> `https://shlink.io/api/error/non-unique-slug`
* `INVALID_SHORTCODE` -> `https://shlink.io/api/error/short-url-not-found`
* `TAG_CONFLICT` -> `https://shlink.io/api/error/tag-conflict`
* `TAG_NOT_FOUND` -> `https://shlink.io/api/error/tag-not-found`
* `MERCURE_NOT_CONFIGURED` -> `https://shlink.io/api/error/mercure-not-configured`
* `INVALID_AUTHORIZATION` -> `https://shlink.io/api/error/missing-authentication`
* `INVALID_API_KEY` -> `https://shlink.io/api/error/invalid-api-key`
* Endpoints previously returning props like `"visitsCount": {number}` no longer do it. There should be an alternative `"visitsSummary": {}` object with the amount nested on it.
* It is no longer possible to order the short URLs list with `orderBy=visitsCount-ASC`/`orderBy=visitsCount-DESC`. Use `orderBy=visits-ASC`/`orderBy=visits-DESC` instead.
* It is no longer possible to get tags with stats using `GET /tags?withStats=true`. Use `GET /tags/stats` endpoint instead.
### Changes in Docker image
* Since openswoole is no longer supported, there are no longer image tags suffixed with `openswoole`. You should migrate to the default or `roadrunner` ones.
* The `non-root` docker tag is no longer published, as all docker images are now running without super-user permissions.
* Due to previous point, it is no longer possible to pass `ENABLE_PERIODIC_VISIT_LOCATE=true` in order to configure a cron job that locates visits periodically.
This was not really needed in the docker image, as visits are located on the fly.
### Changes in integrations
* Credentials in redis URLs should now be URL-encoded, as they are unconditionally url-decoded before being used. Previously, it was possible to customize this behavior via `REDIS_DECODE_CREDENTIALS=true|false`.
* Providing redis URIs in the form of `tcp://password@6.6.6.6:6379` is no longer supported. If you want to provide password with no username, do `tcp://:password@6.6.6.6:6379` instead.
## From v2.x to v3.x
### Changes in REST API

View file

@ -1,18 +1,15 @@
#!/usr/bin/env bash
set -e
if [ "$#" -lt 1 ] || [ "$#" -gt 2 ] || ([ "$#" == 2 ] && [ "$2" != "--no-swoole" ]); then
if [ "$#" -lt 1 ]; then
echo "Usage:" >&2
echo " $0 {version} [--no-swoole]" >&2
echo " $0 {version}" >&2
exit 1
fi
version=$1
noSwoole=$2
phpVersion=$(php -r 'echo PHP_MAJOR_VERSION . "." . PHP_MINOR_VERSION;')
# Openswoole is deprecated. Remove in v4.0.0
[[ $noSwoole ]] && swooleSuffix="" || swooleSuffix="_openswoole"
distId="shlink${version}_php${phpVersion}${swooleSuffix}_dist"
distId="shlink${version}_php${phpVersion}_dist"
builtContent="./build/${distId}"
projectdir=$(pwd)
[[ -f ./composer.phar ]] && composerBin='./composer.phar' || composerBin='composer'
@ -31,18 +28,8 @@ cd "${builtContent}"
# Install dependencies
echo "Installing dependencies with $composerBin..."
composerFlags="--optimize-autoloader --no-progress --no-interaction"
${composerBin} self-update
${composerBin} install --no-dev --prefer-dist $composerFlags
if [[ $noSwoole ]]; then
# If generating a dist not for openswoole, uninstall mezzio-swoole
${composerBin} remove mezzio/mezzio-swoole --with-all-dependencies --update-no-dev $composerFlags
else
# Deprecated. Remove in Shlink v4.0.0
# If generating a dist for openswoole, uninstall RoadRunner
${composerBin} remove spiral/roadrunner spiral/roadrunner-jobs spiral/roadrunner-cli spiral/roadrunner-http --with-all-dependencies --update-no-dev $composerFlags
fi
${composerBin} install --no-dev --prefer-dist --optimize-autoloader --no-progress --no-interaction
# Delete development files
echo 'Deleting dev files...'

View file

@ -43,11 +43,11 @@
"pagerfanta/core": "^3.8",
"pugx/shortid-php": "^1.1",
"ramsey/uuid": "^4.7",
"shlinkio/shlink-common": "dev-main#a309824 as 6.0",
"shlinkio/shlink-common": "dev-main#3f6b243 as 6.0",
"shlinkio/shlink-config": "^2.5",
"shlinkio/shlink-event-dispatcher": "^3.1",
"shlinkio/shlink-importer": "^5.2.1",
"shlinkio/shlink-installer": "^8.7",
"shlinkio/shlink-installer": "dev-develop#9f0d7e5 as 9.0",
"shlinkio/shlink-ip-geolocation": "^3.4",
"shlinkio/shlink-json": "^1.1",
"spiral/roadrunner": "^2023.2",

View file

@ -11,7 +11,6 @@ return (static function (): array {
'redis' => [
'servers' => $redisServers,
'sentinel_service' => EnvVars::REDIS_SENTINEL_SERVICE->loadFromEnv(),
'decode_credentials' => (bool) EnvVars::REDIS_DECODE_CREDENTIALS->loadFromEnv(false),
],
];

View file

@ -21,8 +21,6 @@ return [
Option\Database\DatabaseUnixSocketConfigOption::class,
Option\UrlShortener\ShortDomainHostConfigOption::class,
Option\UrlShortener\ShortDomainSchemaConfigOption::class,
Option\Visit\VisitsWebhooksConfigOption::class,
Option\Visit\OrphanVisitsWebhooksConfigOption::class,
Option\Redirect\BaseUrlRedirectConfigOption::class,
Option\Redirect\InvalidShortUrlRedirectConfigOption::class,
Option\Redirect\Regular404RedirectConfigOption::class,
@ -33,7 +31,6 @@ return [
Option\Worker\TaskWorkerNumConfigOption::class,
Option\Worker\WebWorkerNumConfigOption::class,
Option\Redis\RedisServersConfigOption::class,
Option\Redis\RedisDecodeCredentialsConfigOption::class,
Option\Redis\RedisSentinelServiceConfigOption::class,
Option\Redis\RedisPubSubConfigOption::class,
Option\UrlShortener\ShortCodeLengthOption::class,

View file

@ -47,7 +47,6 @@ return [
'rest' => [
'path' => '/rest',
'middleware' => [
Rest\Middleware\ErrorHandler\BackwardsCompatibleProblemDetailsHandler::class,
Router\Middleware\ImplicitOptionsMiddleware::class,
Rest\Middleware\BodyParserMiddleware::class,
Rest\Middleware\AuthenticationMiddleware::class,

View file

@ -14,9 +14,6 @@ return [
'user' => EnvVars::RABBITMQ_USER->loadFromEnv(),
'password' => EnvVars::RABBITMQ_PASSWORD->loadFromEnv(),
'vhost' => EnvVars::RABBITMQ_VHOST->loadFromEnv('/'),
// Deprecated
'legacy_visits_publishing' => (bool) EnvVars::RABBITMQ_LEGACY_VISITS_PUBLISHING->loadFromEnv(false),
],
];

View file

@ -14,7 +14,7 @@ return (static function (): array {
MIN_SHORT_CODES_LENGTH,
);
$modeFromEnv = EnvVars::SHORT_URL_MODE->loadFromEnv(ShortUrlMode::STRICT->value);
$mode = ShortUrlMode::tryDeprecated($modeFromEnv) ?? ShortUrlMode::STRICT;
$mode = ShortUrlMode::tryFrom($modeFromEnv) ?? ShortUrlMode::STRICT;
return [

View file

@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
use Shlinkio\Shlink\Core\Config\EnvVars;
// Deprecated. Webhooks are no longer supported. To be removed in Shlink 4.0.0
return (static function (): array {
$webhooks = EnvVars::VISITS_WEBHOOKS->loadFromEnv();
return [
'visits_webhooks' => [
'webhooks' => $webhooks === null ? [] : explode(',', $webhooks),
'notify_orphan_visits_to_webhooks' =>
(bool) EnvVars::NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS->loadFromEnv(false),
],
];
})();

View file

@ -9,7 +9,7 @@ use Shlinkio\Shlink\Core\Util\RedirectStatus;
const DEFAULT_DELETE_SHORT_URL_THRESHOLD = 15;
const DEFAULT_SHORT_CODES_LENGTH = 5;
const MIN_SHORT_CODES_LENGTH = 4;
const DEFAULT_REDIRECT_STATUS_CODE = RedirectStatus::STATUS_302; // Deprecated. Default to 307 for Shlink v4
const DEFAULT_REDIRECT_STATUS_CODE = RedirectStatus::STATUS_302;
const DEFAULT_REDIRECT_CACHE_LIFETIME = 30;
const LOCAL_LOCK_FACTORY = 'Shlinkio\Shlink\LocalLockFactory';
const TITLE_TAG_VALUE = '/<title[^>]*>(.*?)<\/title>/i'; // Matches the value inside a html title tag
@ -19,6 +19,5 @@ const DEFAULT_QR_CODE_MARGIN = 0;
const DEFAULT_QR_CODE_FORMAT = 'png';
const DEFAULT_QR_CODE_ERROR_CORRECTION = 'l';
const DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = true;
// Deprecated. Shlink 4.0.0 should change default value to `true`
const DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS = false;
const DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS = true;
const MIN_TASK_WORKERS = 4;

View file

@ -20,19 +20,6 @@ fi
php vendor/bin/shlink-installer init ${flags}
# Periodically run visit:locate every hour, if ENABLE_PERIODIC_VISIT_LOCATE=true was provided and running as root
# FIXME: ENABLE_PERIODIC_VISIT_LOCATE is deprecated. Remove cron support in Shlink 4.0.0
if [ "${ENABLE_PERIODIC_VISIT_LOCATE}" = "true" ] && [ "${SHLINK_USER_ID}" = "root" ]; then
echo "Configuring periodic visit location..."
echo "0 * * * * php /etc/shlink/bin/cli visit:locate -q" > /etc/crontabs/root
/usr/sbin/crond &
fi
if [ "$SHLINK_RUNTIME" = 'openswoole' ]; then
# Openswoole is deprecated. Remove in Shlink 4.0.0
# When restarting the container, openswoole might think it is already in execution
# This forces the app to be started every second until the exit code is 0
until php vendor/bin/laminas mezzio:swoole:start; do sleep 1 ; done
elif [ "$SHLINK_RUNTIME" = 'rr' ]; then
if [ "$SHLINK_RUNTIME" = 'rr' ]; then
./bin/rr serve -c config/roadrunner/.rr.yml
fi

View file

@ -122,11 +122,6 @@
"visitsSummary": {
"$ref": "#/components/schemas/VisitsSummary"
},
"visitsCount": {
"deprecated": true,
"type": "integer",
"description": "The number of visits that this short URL has received."
},
"tags": {
"type": "array",
"items": {

View file

@ -6,7 +6,6 @@
"longUrl",
"deviceLongUrls",
"dateCreated",
"visitsCount",
"visitsSummary",
"tags",
"meta",
@ -36,11 +35,6 @@
"format": "date-time",
"description": "The date in which the short URL was created in ISO format."
},
"visitsCount": {
"deprecated": true,
"type": "integer",
"description": "**[DEPRECATED]** Use `visitsSummary.total` instead."
},
"visitsSummary": {
"$ref": "./VisitsSummary.json"
},

View file

@ -1,6 +1,6 @@
{
"type": "object",
"required": ["tag", "shortUrlsCount", "visitsSummary", "visitsCount"],
"required": ["tag", "shortUrlsCount", "visitsSummary"],
"properties": {
"tag": {
"type": "string",
@ -12,11 +12,6 @@
},
"visitsSummary": {
"$ref": "./VisitsSummary.json"
},
"visitsCount": {
"deprecated": true,
"type": "number",
"description": "**[DEPRECATED]** Use visitsSummary.total instead"
}
}
}

View file

@ -1,22 +1,12 @@
{
"type": "object",
"required": ["nonOrphanVisits", "orphanVisits", "visitsCount", "orphanVisitsCount"],
"required": ["nonOrphanVisits", "orphanVisits"],
"properties": {
"nonOrphanVisits": {
"$ref": "./VisitsSummary.json"
},
"orphanVisits": {
"$ref": "./VisitsSummary.json"
},
"visitsCount": {
"deprecated": true,
"type": "number",
"description": "**[DEPRECATED]** Use nonOrphanVisits.total instead"
},
"orphanVisitsCount": {
"deprecated": true,
"type": "number",
"description": "**[DEPRECATED]** Use orphanVisits.total instead"
}
}
}

View file

@ -15,20 +15,6 @@
{
"$ref": "../parameters/version.json"
},
{
"name": "withStats",
"deprecated": true,
"description": "**[Deprecated]** Use [GET /tags/stats](#/Tags/tagsWithStats) endpoint to get tags with their stats.",
"in": "query",
"required": false,
"schema": {
"type": "string",
"enum": [
"true",
"false"
]
}
},
{
"name": "page",
"in": "query",
@ -88,13 +74,6 @@
"type": "string"
}
},
"stats": {
"description": "The tag stats will be returned only if the withStats param was provided with value 'true'",
"type": "array",
"items": {
"$ref": "../definitions/TagInfo.json"
}
},
"pagination": {
"$ref": "../definitions/Pagination.json"
}

View file

@ -218,7 +218,7 @@ class ListShortUrlsCommand extends Command
'Short URL' => $pickProp('shortUrl'),
'Long URL' => $pickProp('longUrl'),
'Date created' => $pickProp('dateCreated'),
'Visits count' => $pickProp('visitsCount'),
'Visits count' => static fn (array $shortUrl) => $shortUrl['visitsSummary']->total,
];
if ($input->getOption('show-tags')) {
$columnsMap['Tags'] = static fn (array $shortUrl): string => implode(', ', $shortUrl['tags']);

View file

@ -31,7 +31,6 @@ return [
Options\TrackingOptions::class => [ValinorConfigFactory::class, 'config.tracking'],
Options\QrCodeOptions::class => [ValinorConfigFactory::class, 'config.qr_codes'],
Options\RabbitMqOptions::class => [ValinorConfigFactory::class, 'config.rabbitmq'],
Options\WebhookOptions::class => ConfigAbstractFactory::class,
ShortUrl\UrlShortener::class => ConfigAbstractFactory::class,
ShortUrl\ShortUrlService::class => ConfigAbstractFactory::class,
@ -113,8 +112,6 @@ return [
Domain\DomainService::class,
],
Options\WebhookOptions::class => ['config.visits_webhooks'],
ShortUrl\UrlShortener::class => [
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class,
'em',

View file

@ -37,7 +37,6 @@ return (static function (): array {
EventDispatcher\Mercure\NotifyVisitToMercure::class,
EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class,
EventDispatcher\RedisPubSub\NotifyVisitToRedis::class,
EventDispatcher\NotifyVisitToWebHooks::class,
EventDispatcher\UpdateGeoLiteDb::class,
],
EventDispatcher\Event\ShortUrlCreated::class => [
@ -66,7 +65,6 @@ return (static function (): array {
EventDispatcher\LocateVisit::class => ConfigAbstractFactory::class,
EventDispatcher\Matomo\SendVisitToMatomo::class => ConfigAbstractFactory::class,
EventDispatcher\LocateUnlocatedVisits::class => ConfigAbstractFactory::class,
EventDispatcher\NotifyVisitToWebHooks::class => ConfigAbstractFactory::class,
EventDispatcher\Mercure\NotifyVisitToMercure::class => ConfigAbstractFactory::class,
EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => ConfigAbstractFactory::class,
EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class => ConfigAbstractFactory::class,
@ -104,9 +102,6 @@ return (static function (): array {
EventDispatcher\LocateUnlocatedVisits::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
EventDispatcher\NotifyVisitToWebHooks::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
],
],
@ -119,14 +114,6 @@ return (static function (): array {
EventDispatcherInterface::class,
],
EventDispatcher\LocateUnlocatedVisits::class => [VisitLocator::class, VisitToLocationHelper::class],
EventDispatcher\NotifyVisitToWebHooks::class => [
'httpClient',
'em',
'Logger_Shlink',
Options\WebhookOptions::class,
ShortUrl\Transformer\ShortUrlDataTransformer::class,
Options\AppOptions::class,
],
EventDispatcher\Mercure\NotifyVisitToMercure::class => [
MercureHubPublishingHelper::class,
EventDispatcher\PublishingUpdatesGenerator::class,
@ -144,7 +131,6 @@ return (static function (): array {
EventDispatcher\PublishingUpdatesGenerator::class,
'em',
'Logger_Shlink',
Visit\Transformer\OrphanVisitDataTransformer::class,
Options\RabbitMqOptions::class,
],
EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class => [
@ -187,7 +173,6 @@ return (static function (): array {
Options\RabbitMqOptions::class,
'config.redis.pub_sub_enabled',
MercureOptions::class,
Options\WebhookOptions::class,
GeoLite2Options::class,
MatomoOptions::class,
],

View file

@ -24,7 +24,6 @@ enum EnvVars: string
case CACHE_NAMESPACE = 'CACHE_NAMESPACE';
case REDIS_SERVERS = 'REDIS_SERVERS';
case REDIS_SENTINEL_SERVICE = 'REDIS_SENTINEL_SERVICE';
case REDIS_DECODE_CREDENTIALS = 'REDIS_DECODE_CREDENTIALS';
case REDIS_PUB_SUB_ENABLED = 'REDIS_PUB_SUB_ENABLED';
case MERCURE_PUBLIC_HUB_URL = 'MERCURE_PUBLIC_HUB_URL';
case MERCURE_INTERNAL_HUB_URL = 'MERCURE_INTERNAL_HUB_URL';
@ -36,8 +35,6 @@ enum EnvVars: string
case RABBITMQ_PASSWORD = 'RABBITMQ_PASSWORD';
case RABBITMQ_VHOST = 'RABBITMQ_VHOST';
case RABBITMQ_USE_SSL = 'RABBITMQ_USE_SSL';
/** @deprecated */
case RABBITMQ_LEGACY_VISITS_PUBLISHING = 'RABBITMQ_LEGACY_VISITS_PUBLISHING';
case MATOMO_ENABLED = 'MATOMO_ENABLED';
case MATOMO_BASE_URL = 'MATOMO_BASE_URL';
case MATOMO_SITE_ID = 'MATOMO_SITE_ID';
@ -74,10 +71,6 @@ enum EnvVars: string
case REDIRECT_APPEND_EXTRA_PATH = 'REDIRECT_APPEND_EXTRA_PATH';
case TIMEZONE = 'TIMEZONE';
case MULTI_SEGMENT_SLUGS_ENABLED = 'MULTI_SEGMENT_SLUGS_ENABLED';
/** @deprecated */
case VISITS_WEBHOOKS = 'VISITS_WEBHOOKS';
/** @deprecated */
case NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS = 'NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS';
public function loadFromEnv(mixed $default = null): mixed
{

View file

@ -8,19 +8,17 @@ use Shlinkio\Shlink\Common\Mercure\MercureOptions;
use Shlinkio\Shlink\Core\EventDispatcher;
use Shlinkio\Shlink\Core\Matomo\MatomoOptions;
use Shlinkio\Shlink\Core\Options\RabbitMqOptions;
use Shlinkio\Shlink\Core\Options\WebhookOptions;
use Shlinkio\Shlink\EventDispatcher\Listener\EnabledListenerCheckerInterface;
use Shlinkio\Shlink\IpGeolocation\GeoLite2\GeoLite2Options;
class EnabledListenerChecker implements EnabledListenerCheckerInterface
readonly class EnabledListenerChecker implements EnabledListenerCheckerInterface
{
public function __construct(
private readonly RabbitMqOptions $rabbitMqOptions,
private readonly bool $redisPubSubEnabled,
private readonly MercureOptions $mercureOptions,
private readonly WebhookOptions $webhookOptions,
private readonly GeoLite2Options $geoLiteOptions,
private readonly MatomoOptions $matomoOptions,
private RabbitMqOptions $rabbitMqOptions,
private bool $redisPubSubEnabled,
private MercureOptions $mercureOptions,
private GeoLite2Options $geoLiteOptions,
private MatomoOptions $matomoOptions,
) {
}
@ -38,7 +36,6 @@ class EnabledListenerChecker implements EnabledListenerCheckerInterface
EventDispatcher\Mercure\NotifyVisitToMercure::class,
EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => $this->mercureOptions->isEnabled(),
EventDispatcher\Matomo\SendVisitToMatomo::class => $this->matomoOptions->enabled,
EventDispatcher\NotifyVisitToWebHooks::class => $this->webhookOptions->hasWebhooks(),
EventDispatcher\UpdateGeoLiteDb::class => $this->geoLiteOptions->hasLicenseKey(),
default => false, // Any unknown async listener should not be enabled by default
};

View file

@ -1,101 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\EventDispatcher;
use Doctrine\ORM\EntityManagerInterface;
use Fig\Http\Message\RequestMethodInterface;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Promise\Promise;
use GuzzleHttp\Promise\PromiseInterface;
use GuzzleHttp\Promise\Utils;
use GuzzleHttp\RequestOptions;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated;
use Shlinkio\Shlink\Core\Options\AppOptions;
use Shlinkio\Shlink\Core\Options\WebhookOptions;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Throwable;
use function array_map;
/** @deprecated */
class NotifyVisitToWebHooks
{
public function __construct(
private readonly ClientInterface $httpClient,
private readonly EntityManagerInterface $em,
private readonly LoggerInterface $logger,
private readonly WebhookOptions $webhookOptions,
private readonly DataTransformerInterface $transformer,
private readonly AppOptions $appOptions,
) {
}
public function __invoke(VisitLocated $shortUrlLocated): void
{
if (! $this->webhookOptions->hasWebhooks()) {
return;
}
$visitId = $shortUrlLocated->visitId;
/** @var Visit|null $visit */
$visit = $this->em->find(Visit::class, $visitId);
if ($visit === null) {
$this->logger->warning('Tried to notify webhooks for visit with id "{visitId}", but it does not exist.', [
'visitId' => $visitId,
]);
return;
}
if ($visit->isOrphan() && ! $this->webhookOptions->notifyOrphanVisits()) {
return;
}
$requestOptions = $this->buildRequestOptions($visit);
$requestPromises = $this->performRequests($requestOptions, $visitId);
// Wait for all the promises to finish, ignoring rejections, as in those cases we only want to log the error.
Utils::settle($requestPromises)->wait();
}
private function buildRequestOptions(Visit $visit): array
{
$payload = ['visit' => $visit->jsonSerialize()];
$shortUrl = $visit->getShortUrl();
if ($shortUrl !== null) {
$payload['shortUrl'] = $this->transformer->transform($shortUrl);
}
return [
RequestOptions::TIMEOUT => 10,
RequestOptions::JSON => $payload,
RequestOptions::HEADERS => ['User-Agent' => $this->appOptions->__toString()],
];
}
/**
* @param Promise[] $requestOptions
*/
private function performRequests(array $requestOptions, string $visitId): array
{
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(),
);
}
private function logWebhookFailure(string $webhook, string $visitId, Throwable $e): void
{
$this->logger->warning('Failed to notify visit with id "{visitId}" to webhook "{webhook}". {e}', [
'visitId' => $visitId,
'webhook' => $webhook,
'e' => $e,
]);
}
}

View file

@ -6,15 +6,11 @@ namespace Shlinkio\Shlink\Core\EventDispatcher\RabbitMq;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
use Shlinkio\Shlink\Common\UpdatePublishing\PublishingHelperInterface;
use Shlinkio\Shlink\Common\UpdatePublishing\Update;
use Shlinkio\Shlink\Core\EventDispatcher\Async\AbstractNotifyVisitListener;
use Shlinkio\Shlink\Core\EventDispatcher\Async\RemoteSystem;
use Shlinkio\Shlink\Core\EventDispatcher\PublishingUpdatesGeneratorInterface;
use Shlinkio\Shlink\Core\EventDispatcher\Topic;
use Shlinkio\Shlink\Core\Options\RabbitMqOptions;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
class NotifyVisitToRabbitMq extends AbstractNotifyVisitListener
{
@ -23,42 +19,11 @@ class NotifyVisitToRabbitMq extends AbstractNotifyVisitListener
PublishingUpdatesGeneratorInterface $updatesGenerator,
EntityManagerInterface $em,
LoggerInterface $logger,
private readonly DataTransformerInterface $orphanVisitTransformer,
private readonly RabbitMqOptions $options,
) {
parent::__construct($rabbitMqHelper, $updatesGenerator, $em, $logger);
}
/**
* @return Update[]
*/
protected function determineUpdatesForVisit(Visit $visit): array
{
// Once the two deprecated cases below have been removed, make parent method private
if (! $this->options->legacyVisitsPublishing) {
return parent::determineUpdatesForVisit($visit);
}
// This was defined incorrectly.
// According to the spec, both the visit and the short URL it belongs to, should be published.
// The shape should be ['visit' => [...], 'shortUrl' => ?[...]]
// However, this would be a breaking change, so we need a flag that determines the shape of the payload.
return $visit->isOrphan()
? [
Update::forTopicAndPayload(
Topic::NEW_ORPHAN_VISIT->value,
$this->orphanVisitTransformer->transform($visit),
),
]
: [
Update::forTopicAndPayload(Topic::NEW_VISIT->value, $visit->jsonSerialize()),
Update::forTopicAndPayload(
Topic::newShortUrlVisit($visit->getShortUrl()?->getShortCode()),
$visit->jsonSerialize(),
),
];
}
protected function isEnabled(): bool
{
return $this->options->enabled;

View file

@ -4,12 +4,10 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Options;
final class RabbitMqOptions
final readonly class RabbitMqOptions
{
public function __construct(
public readonly bool $enabled = false,
/** @deprecated */
public readonly bool $legacyVisitsPublishing = false,
public bool $enabled = false,
) {
}
}

View file

@ -1,41 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Options;
use Laminas\Stdlib\AbstractOptions;
/** @deprecated */
class WebhookOptions extends AbstractOptions
{
protected $__strictMode__ = false; // phpcs:ignore
private array $webhooks = [];
private bool $notifyOrphanVisitsToWebhooks = false;
public function webhooks(): array
{
return $this->webhooks;
}
public function hasWebhooks(): bool
{
return ! empty($this->webhooks);
}
protected function setWebhooks(array $webhooks): void
{
$this->webhooks = $webhooks;
}
public function notifyOrphanVisits(): bool
{
return $this->notifyOrphanVisitsToWebhooks;
}
protected function setNotifyOrphanVisitsToWebhooks(bool $notifyOrphanVisitsToWebhooks): void
{
$this->notifyOrphanVisitsToWebhooks = $notifyOrphanVisitsToWebhooks;
}
}

View file

@ -6,10 +6,4 @@ enum ShortUrlMode: string
{
case STRICT = 'strict';
case LOOSE = 'loose';
/** @deprecated */
public static function tryDeprecated(string $mode): ?self
{
return $mode === 'loosely' ? self::LOOSE : self::tryFrom($mode);
}
}

View file

@ -39,9 +39,6 @@ class ShortUrlDataTransformer implements DataTransformerInterface
$shortUrl->getVisitsCount(),
$shortUrl->nonBotVisitsCount(),
),
// Deprecated
'visitsCount' => $shortUrl->getVisitsCount(),
];
}

View file

@ -10,15 +10,13 @@ enum OrderableField: string
case SHORT_URLS_COUNT = 'shortUrlsCount';
case VISITS = 'visits';
case NON_BOT_VISITS = 'nonBotVisits';
/** @deprecated Use VISITS instead */
case VISITS_COUNT = 'visitsCount';
public static function toSnakeCaseValidField(?string $field): self
public static function toValidField(?string $field): self
{
$parsed = $field !== null ? self::tryFrom($field) : self::TAG;
return match ($parsed) {
self::VISITS_COUNT, null => self::VISITS,
default => $parsed,
};
if ($field === null) {
return self::TAG;
}
return self::tryFrom($field) ?? self::TAG;
}
}

View file

@ -7,13 +7,13 @@ namespace Shlinkio\Shlink\Core\Tag\Model;
use JsonSerializable;
use Shlinkio\Shlink\Core\Visit\Model\VisitsSummary;
final class TagInfo implements JsonSerializable
final readonly class TagInfo implements JsonSerializable
{
public readonly VisitsSummary $visitsSummary;
public VisitsSummary $visitsSummary;
public function __construct(
public readonly string $tag,
public readonly int $shortUrlsCount,
public string $tag,
public int $shortUrlsCount,
int $visitsCount,
?int $nonBotVisitsCount = null,
) {
@ -36,9 +36,6 @@ final class TagInfo implements JsonSerializable
'tag' => $this->tag,
'shortUrlsCount' => $this->shortUrlsCount,
'visitsSummary' => $this->visitsSummary,
// Deprecated
'visitsCount' => $this->visitsSummary->total,
];
}
}

View file

@ -14,8 +14,6 @@ final class TagsParams extends AbstractInfinitePaginableListParams
private function __construct(
public readonly ?string $searchTerm,
public readonly Ordering $orderBy,
/** @deprecated */
public readonly bool $withStats,
?int $page,
?int $itemsPerPage,
) {
@ -27,7 +25,6 @@ final class TagsParams extends AbstractInfinitePaginableListParams
return new self(
$query['searchTerm'] ?? null,
Ordering::fromTuple(isset($query['orderBy']) ? parseOrderBy($query['orderBy']) : [null, null]),
($query['withStats'] ?? null) === 'true',
isset($query['page']) ? (int) $query['page'] : null,
isset($query['itemsPerPage']) ? (int) $query['itemsPerPage'] : null,
);

View file

@ -43,7 +43,7 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito
*/
public function findTagsWithInfo(?TagsListFiltering $filtering = null): array
{
$orderField = OrderableField::toSnakeCaseValidField($filtering?->orderBy?->field);
$orderField = OrderableField::toValidField($filtering?->orderBy?->field);
$orderDir = $filtering?->orderBy?->direction ?? 'ASC';
$apiKey = $filtering?->apiKey;
$conn = $this->getEntityManager()->getConnection();

View file

@ -6,10 +6,10 @@ namespace Shlinkio\Shlink\Core\Visit\Model;
use JsonSerializable;
final class VisitsStats implements JsonSerializable
final readonly class VisitsStats implements JsonSerializable
{
private readonly VisitsSummary $nonOrphanVisitsSummary;
private readonly VisitsSummary $orphanVisitsSummary;
private VisitsSummary $nonOrphanVisitsSummary;
private VisitsSummary $orphanVisitsSummary;
public function __construct(
int $nonOrphanVisitsTotal,
@ -32,10 +32,6 @@ final class VisitsStats implements JsonSerializable
return [
'nonOrphanVisits' => $this->nonOrphanVisitsSummary,
'orphanVisits' => $this->orphanVisitsSummary,
// Deprecated
'visitsCount' => $this->nonOrphanVisitsSummary->total,
'orphanVisitsCount' => $this->orphanVisitsSummary->total,
];
}
}

View file

@ -10,7 +10,7 @@ use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
class QrCodeTest extends ApiTestCase
{
#[Test]
public function returnsNotFoundWhenShortUrlIsNotEnabled(): void
public function returnsQrCodeEvenIfShortUrlIsNotEnabled(): void
{
// The QR code successfully resolves at first
$response = $this->callShortUrl('custom/qr-code');
@ -20,8 +20,8 @@ class QrCodeTest extends ApiTestCase
$this->callShortUrl('custom');
$this->callShortUrl('custom');
// After 2 visits, the QR code should return a 404
$response = $this->callShortUrl('custom/qr-code');
self::assertEquals(404, $response->getStatusCode());
// After 2 visits, the short URL returns a 404, but the QR code should still work
self::assertEquals(404, $this->callShortUrl('custom')->getStatusCode());
self::assertEquals(200, $this->callShortUrl('custom/qr-code')->getStatusCode());
}
}

View file

@ -197,13 +197,6 @@ class TagRepositoryTest extends DatabaseTestCase
['another', 0, 0, 0],
],
];
yield 'visits count DESC ordering and limit' => [
new TagsListFiltering(2, null, null, Ordering::fromTuple([OrderableField::VISITS_COUNT->value, 'DESC'])),
[
['foo', 2, 4, 3],
['bar', 3, 3, 2],
],
];
yield 'api key' => [new TagsListFiltering(null, null, null, null, ApiKey::fromMeta(
ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls()),
)), [

View file

@ -265,7 +265,7 @@ class QrCodeActionTest extends TestCase
$this->urlResolver,
new ShortUrlStringifier(['domain' => 's.test']),
new NullLogger(),
$options ?? new QrCodeOptions(),
$options ?? new QrCodeOptions(enabledForDisabledShortUrls: false),
);
}
}

View file

@ -12,7 +12,6 @@ use Shlinkio\Shlink\Core\EventDispatcher\Helper\EnabledListenerChecker;
use Shlinkio\Shlink\Core\EventDispatcher\Matomo\SendVisitToMatomo;
use Shlinkio\Shlink\Core\EventDispatcher\Mercure\NotifyNewShortUrlToMercure;
use Shlinkio\Shlink\Core\EventDispatcher\Mercure\NotifyVisitToMercure;
use Shlinkio\Shlink\Core\EventDispatcher\NotifyVisitToWebHooks;
use Shlinkio\Shlink\Core\EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq;
use Shlinkio\Shlink\Core\EventDispatcher\RabbitMq\NotifyVisitToRabbitMq;
use Shlinkio\Shlink\Core\EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis;
@ -20,7 +19,6 @@ use Shlinkio\Shlink\Core\EventDispatcher\RedisPubSub\NotifyVisitToRedis;
use Shlinkio\Shlink\Core\EventDispatcher\UpdateGeoLiteDb;
use Shlinkio\Shlink\Core\Matomo\MatomoOptions;
use Shlinkio\Shlink\Core\Options\RabbitMqOptions;
use Shlinkio\Shlink\Core\Options\WebhookOptions;
use Shlinkio\Shlink\IpGeolocation\GeoLite2\GeoLite2Options;
class EnabledListenerCheckerTest extends TestCase
@ -41,7 +39,6 @@ class EnabledListenerCheckerTest extends TestCase
[NotifyVisitToMercure::class],
[NotifyNewShortUrlToMercure::class],
[SendVisitToMatomo::class],
[NotifyVisitToWebHooks::class],
[UpdateGeoLiteDb::class],
];
}
@ -68,7 +65,6 @@ class EnabledListenerCheckerTest extends TestCase
NotifyNewShortUrlToRedis::class => false,
NotifyVisitToMercure::class => false,
NotifyNewShortUrlToMercure::class => false,
NotifyVisitToWebHooks::class => false,
UpdateGeoLiteDb::class => false,
'unknown' => false,
]];
@ -79,7 +75,6 @@ class EnabledListenerCheckerTest extends TestCase
NotifyNewShortUrlToRedis::class => true,
NotifyVisitToMercure::class => false,
NotifyNewShortUrlToMercure::class => false,
NotifyVisitToWebHooks::class => false,
UpdateGeoLiteDb::class => false,
'unknown' => false,
]];
@ -90,18 +85,6 @@ class EnabledListenerCheckerTest extends TestCase
NotifyNewShortUrlToRedis::class => false,
NotifyVisitToMercure::class => true,
NotifyNewShortUrlToMercure::class => true,
NotifyVisitToWebHooks::class => false,
UpdateGeoLiteDb::class => false,
'unknown' => false,
]];
yield 'Webhooks' => [self::checker(webhooksEnabled: true), [
NotifyVisitToRabbitMq::class => false,
NotifyNewShortUrlToRabbitMq::class => false,
NotifyVisitToRedis::class => false,
NotifyNewShortUrlToRedis::class => false,
NotifyVisitToMercure::class => false,
NotifyNewShortUrlToMercure::class => false,
NotifyVisitToWebHooks::class => true,
UpdateGeoLiteDb::class => false,
'unknown' => false,
]];
@ -112,7 +95,6 @@ class EnabledListenerCheckerTest extends TestCase
NotifyNewShortUrlToRedis::class => false,
NotifyVisitToMercure::class => false,
NotifyNewShortUrlToMercure::class => false,
NotifyVisitToWebHooks::class => false,
UpdateGeoLiteDb::class => true,
'unknown' => false,
]];
@ -124,7 +106,6 @@ class EnabledListenerCheckerTest extends TestCase
NotifyVisitToMercure::class => false,
NotifyNewShortUrlToMercure::class => false,
SendVisitToMatomo::class => true,
NotifyVisitToWebHooks::class => false,
UpdateGeoLiteDb::class => false,
'unknown' => false,
]];
@ -135,7 +116,6 @@ class EnabledListenerCheckerTest extends TestCase
NotifyNewShortUrlToRedis::class => false,
NotifyVisitToMercure::class => false,
NotifyNewShortUrlToMercure::class => false,
NotifyVisitToWebHooks::class => false,
UpdateGeoLiteDb::class => false,
'unknown' => false,
]];
@ -143,7 +123,6 @@ class EnabledListenerCheckerTest extends TestCase
rabbitMqEnabled: true,
redisPubSubEnabled: true,
mercureEnabled: true,
webhooksEnabled: true,
geoLiteEnabled: true,
matomoEnabled: true,
), [
@ -154,7 +133,6 @@ class EnabledListenerCheckerTest extends TestCase
NotifyVisitToMercure::class => true,
NotifyNewShortUrlToMercure::class => true,
SendVisitToMatomo::class => true,
NotifyVisitToWebHooks::class => true,
UpdateGeoLiteDb::class => true,
'unknown' => false,
]];
@ -164,7 +142,6 @@ class EnabledListenerCheckerTest extends TestCase
bool $rabbitMqEnabled = false,
bool $redisPubSubEnabled = false,
bool $mercureEnabled = false,
bool $webhooksEnabled = false,
bool $geoLiteEnabled = false,
bool $matomoEnabled = false,
): EnabledListenerChecker {
@ -172,7 +149,6 @@ class EnabledListenerCheckerTest extends TestCase
new RabbitMqOptions(enabled: $rabbitMqEnabled),
$redisPubSubEnabled,
new MercureOptions(publicHubUrl: $mercureEnabled ? 'the-url' : null),
new WebhookOptions(['webhooks' => $webhooksEnabled ? ['foo', 'bar'] : []]),
new GeoLite2Options(licenseKey: $geoLiteEnabled ? 'the-key' : null),
new MatomoOptions(enabled: $matomoEnabled),
);

View file

@ -1,144 +0,0 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\EventDispatcher;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
use Fig\Http\Message\RequestMethodInterface;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Promise\FulfilledPromise;
use GuzzleHttp\Promise\RejectedPromise;
use GuzzleHttp\RequestOptions;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated;
use Shlinkio\Shlink\Core\EventDispatcher\NotifyVisitToWebHooks;
use Shlinkio\Shlink\Core\Options\AppOptions;
use Shlinkio\Shlink\Core\Options\WebhookOptions;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\Visitor;
use function count;
use function Shlinkio\Shlink\Core\ArrayUtils\contains;
class NotifyVisitToWebHooksTest extends TestCase
{
private MockObject & ClientInterface $httpClient;
private MockObject & EntityManagerInterface $em;
private MockObject & LoggerInterface $logger;
protected function setUp(): void
{
$this->httpClient = $this->createMock(ClientInterface::class);
$this->em = $this->createMock(EntityManagerInterface::class);
$this->logger = $this->createMock(LoggerInterface::class);
}
#[Test]
public function emptyWebhooksMakeNoFurtherActions(): void
{
$this->em->expects($this->never())->method('find');
$this->createListener([])(new VisitLocated('1'));
}
#[Test]
public function invalidVisitDoesNotPerformAnyRequest(): void
{
$this->em->expects($this->once())->method('find')->with(Visit::class, '1')->willReturn(null);
$this->httpClient->expects($this->never())->method('requestAsync');
$this->logger->expects($this->once())->method('warning')->with(
'Tried to notify webhooks for visit with id "{visitId}", but it does not exist.',
['visitId' => '1'],
);
$this->createListener(['foo', 'bar'])(new VisitLocated('1'));
}
#[Test]
public function orphanVisitDoesNotPerformAnyRequestWhenDisabled(): void
{
$this->em->expects($this->once())->method('find')->with(Visit::class, '1')->willReturn(
Visit::forBasePath(Visitor::emptyInstance()),
);
$this->httpClient->expects($this->never())->method('requestAsync');
$this->logger->expects($this->never())->method('warning');
$this->createListener(['foo', 'bar'], false)(new VisitLocated('1'));
}
#[Test, DataProvider('provideVisits')]
public function expectedRequestsArePerformedToWebhooks(Visit $visit, array $expectedResponseKeys): void
{
$webhooks = ['foo', 'invalid', 'bar', 'baz'];
$invalidWebhooks = ['invalid', 'baz'];
$this->em->expects($this->once())->method('find')->with(Visit::class, '1')->willReturn($visit);
$this->httpClient->expects($this->exactly(count($webhooks)))->method('requestAsync')->with(
RequestMethodInterface::METHOD_POST,
$this->istype('string'),
$this->callback(function (array $requestOptions) use ($expectedResponseKeys) {
Assert::assertArrayHasKey(RequestOptions::HEADERS, $requestOptions);
Assert::assertArrayHasKey(RequestOptions::JSON, $requestOptions);
Assert::assertArrayHasKey(RequestOptions::TIMEOUT, $requestOptions);
Assert::assertEquals(10, $requestOptions[RequestOptions::TIMEOUT]);
Assert::assertEquals(['User-Agent' => 'Shlink:v1.2.3'], $requestOptions[RequestOptions::HEADERS]);
$json = $requestOptions[RequestOptions::JSON];
Assert::assertCount(count($expectedResponseKeys), $json);
foreach ($expectedResponseKeys as $key) {
Assert::assertArrayHasKey($key, $json);
}
return true;
}),
)->willReturnCallback(function ($_, $webhook) use ($invalidWebhooks) {
$shouldReject = contains($webhook, $invalidWebhooks);
return $shouldReject ? new RejectedPromise(new Exception('')) : new FulfilledPromise('');
});
$this->logger->expects($this->exactly(count($invalidWebhooks)))->method('warning')->with(
'Failed to notify visit with id "{visitId}" to webhook "{webhook}". {e}',
$this->callback(function (array $extra): bool {
Assert::assertArrayHasKey('webhook', $extra);
Assert::assertArrayHasKey('visitId', $extra);
Assert::assertArrayHasKey('e', $extra);
return true;
}),
);
$this->createListener($webhooks)(new VisitLocated('1'));
}
public static function provideVisits(): iterable
{
yield 'regular visit' => [
Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance()),
['shortUrl', 'visit'],
];
yield 'orphan visit' => [Visit::forBasePath(Visitor::emptyInstance()), ['visit']];
}
private function createListener(array $webhooks, bool $notifyOrphanVisits = true): NotifyVisitToWebHooks
{
return new NotifyVisitToWebHooks(
$this->httpClient,
$this->em,
$this->logger,
new WebhookOptions(
['webhooks' => $webhooks, 'notify_orphan_visits_to_webhooks' => $notifyOrphanVisits],
),
new ShortUrlDataTransformer(new ShortUrlStringifier([])),
new AppOptions('Shlink', '1.2.3'),
);
}
}

View file

@ -53,7 +53,6 @@ class PublishingUpdatesGeneratorTest extends TestCase
'longUrl' => 'https://longUrl',
'deviceLongUrls' => $shortUrl->deviceLongUrls(),
'dateCreated' => $shortUrl->getDateCreated()->toAtomString(),
'visitsCount' => 0,
'tags' => [],
'meta' => [
'validSince' => null,
@ -128,7 +127,6 @@ class PublishingUpdatesGeneratorTest extends TestCase
'longUrl' => 'https://longUrl',
'deviceLongUrls' => $shortUrl->deviceLongUrls(),
'dateCreated' => $shortUrl->getDateCreated()->toAtomString(),
'visitsCount' => 0,
'tags' => [],
'meta' => [
'validSince' => null,

View file

@ -7,7 +7,6 @@ namespace ShlinkioTest\Shlink\Core\EventDispatcher\RabbitMq;
use Doctrine\ORM\EntityManagerInterface;
use DomainException;
use Exception;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
@ -24,7 +23,6 @@ use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\Visitor;
use Shlinkio\Shlink\Core\Visit\Transformer\OrphanVisitDataTransformer;
use Throwable;
use function array_walk;
@ -132,9 +130,8 @@ class NotifyVisitToRabbitMqTest extends TestCase
yield [new DomainException('DomainException Error')];
}
#[Test, DataProvider('provideLegacyPayloads')]
#[Test, DataProvider('providePayloads')]
public function expectedPayloadIsPublishedDependingOnConfig(
bool $legacy,
Visit $visit,
callable $setup,
callable $expect,
@ -144,44 +141,12 @@ class NotifyVisitToRabbitMqTest extends TestCase
$setup($this->updatesGenerator);
$expect($this->helper, $this->updatesGenerator);
($this->listener(new RabbitMqOptions(true, $legacy)))(new VisitLocated($visitId));
($this->listener())(new VisitLocated($visitId));
}
public static function provideLegacyPayloads(): iterable
public static function providePayloads(): iterable
{
yield 'legacy non-orphan visit' => [
true,
$visit = Visit::forValidShortUrl(ShortUrl::withLongUrl('https://longUrl'), Visitor::emptyInstance()),
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;
Assert::assertEquals($payload, $visit->jsonSerialize());
Assert::assertArrayNotHasKey('visitedUrl', $payload);
Assert::assertArrayNotHasKey('type', $payload);
Assert::assertArrayNotHasKey('visit', $payload);
Assert::assertArrayNotHasKey('shortUrl', $payload);
return true;
}));
},
];
yield 'legacy orphan visit' => [
true,
Visit::forBasePath(Visitor::emptyInstance()),
static fn () => null,
function (MockObject & PublishingHelperInterface $helper): void {
$helper->method('publishUpdate')->with(self::callback(function (Update $update): bool {
$payload = $update->payload;
Assert::assertArrayHasKey('visitedUrl', $payload);
Assert::assertArrayHasKey('type', $payload);
return true;
}));
},
];
yield 'non-legacy non-orphan visit' => [
false,
yield 'non-orphan visit' => [
Visit::forValidShortUrl(ShortUrl::withLongUrl('https://longUrl'), Visitor::emptyInstance()),
function (MockObject & PublishingUpdatesGeneratorInterface $updatesGenerator): void {
$update = Update::forTopicAndPayload('', []);
@ -195,8 +160,7 @@ class NotifyVisitToRabbitMqTest extends TestCase
$helper->expects(self::exactly(2))->method('publishUpdate')->with(self::isInstanceOf(Update::class));
},
];
yield 'non-legacy orphan visit' => [
false,
yield 'orphan visit' => [
Visit::forBasePath(Visitor::emptyInstance()),
function (MockObject & PublishingUpdatesGeneratorInterface $updatesGenerator): void {
$update = Update::forTopicAndPayload('', []);
@ -217,8 +181,7 @@ class NotifyVisitToRabbitMqTest extends TestCase
$this->updatesGenerator,
$this->em,
$this->logger,
new OrphanVisitDataTransformer(),
$options ?? new RabbitMqOptions(enabled: true, legacyVisitsPublishing: false),
$options ?? new RabbitMqOptions(enabled: true),
);
}
}

View file

@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\ShortUrl\Model;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode;
class ShortUrlModeTest extends TestCase
{
#[Test, DataProvider('provideModes')]
public function deprecatedValuesAreProperlyParsed(string $mode, ?ShortUrlMode $expected): void
{
self::assertSame($expected, ShortUrlMode::tryDeprecated($mode));
}
public static function provideModes(): iterable
{
yield 'invalid' => ['invalid', null];
yield 'foo' => ['foo', null];
yield 'loose' => ['loose', ShortUrlMode::LOOSE];
yield 'loosely' => ['loosely', ShortUrlMode::LOOSE];
yield 'strict' => ['strict', ShortUrlMode::STRICT];
}
}

View file

@ -55,7 +55,6 @@ return [
Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class => ConfigAbstractFactory::class,
Middleware\ShortUrl\OverrideDomainMiddleware::class => ConfigAbstractFactory::class,
Middleware\Mercure\NotConfiguredMercureErrorHandler::class => ConfigAbstractFactory::class,
Middleware\ErrorHandler\BackwardsCompatibleProblemDetailsHandler::class => InvokableFactory::class,
],
],

View file

@ -8,14 +8,11 @@ use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Core\Tag\Model\TagsParams;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware;
use function array_map;
class ListTagsAction extends AbstractRestAction
{
use PagerfantaUtilsTrait;
@ -32,17 +29,8 @@ class ListTagsAction extends AbstractRestAction
$params = TagsParams::fromRawData($request->getQueryParams());
$apiKey = AuthenticationMiddleware::apiKeyFromRequest($request);
if (! $params->withStats) {
return new JsonResponse([
'tags' => $this->serializePaginator($this->tagService->listTags($params, $apiKey)),
]);
}
// 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'] = array_map(static fn (TagInfo $info) => $info->tag, [...$tagsInfo]);
return new JsonResponse(['tags' => $rawTags]);
return new JsonResponse([
'tags' => $this->serializePaginator($this->tagService->listTags($params, $apiKey)),
]);
}
}

View file

@ -40,8 +40,8 @@ enum Role: string
public static function toSpec(ApiKeyRole $role, ?string $context = null): Specification
{
return match ($role->role()) {
self::AUTHORED_SHORT_URLS => new BelongsToApiKey($role->apiKey(), $context),
return match ($role->role) {
self::AUTHORED_SHORT_URLS => new BelongsToApiKey($role->apiKey, $context),
self::DOMAIN_SPECIFIC => new BelongsToDomain(self::domainIdFromMeta($role->meta()), $context),
default => Spec::andX(),
};
@ -49,8 +49,8 @@ enum Role: string
public static function toInlinedSpec(ApiKeyRole $role): Specification
{
return match ($role->role()) {
self::AUTHORED_SHORT_URLS => Spec::andX(new BelongsToApiKeyInlined($role->apiKey())),
return match ($role->role) {
self::AUTHORED_SHORT_URLS => Spec::andX(new BelongsToApiKeyInlined($role->apiKey)),
self::DOMAIN_SPECIFIC => Spec::andX(new BelongsToDomainInlined(self::domainIdFromMeta($role->meta()))),
default => Spec::andX(),
};

View file

@ -156,7 +156,7 @@ class ApiKey extends AbstractEntity
*/
public function mapRoles(callable $fun): array
{
return $this->roles->map(fn (ApiKeyRole $role) => $fun($role->role(), $role->meta()))->getValues();
return $this->roles->map(fn (ApiKeyRole $role) => $fun($role->role, $role->meta()))->getValues();
}
public function registerRole(RoleDefinition $roleDefinition): void

View file

@ -13,22 +13,6 @@ class ApiKeyRole extends AbstractEntity
{
}
/**
* @deprecated Use property access directly
*/
public function role(): Role
{
return $this->role;
}
/**
* @deprecated Use property access directly
*/
public function apiKey(): ApiKey
{
return $this->apiKey;
}
public function meta(): array
{
return $this->meta;

View file

@ -1,99 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Exception;
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
use Shlinkio\Shlink\Core\Exception\DeleteShortUrlException;
use Shlinkio\Shlink\Core\Exception\DomainNotFoundException;
use Shlinkio\Shlink\Core\Exception\ForbiddenTagOperationException;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Exception\TagConflictException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Exception\ValidationException;
use function end;
use function explode;
/** @deprecated */
class BackwardsCompatibleProblemDetailsException extends RuntimeException implements ProblemDetailsExceptionInterface
{
private function __construct(private readonly ProblemDetailsExceptionInterface $e)
{
parent::__construct($e->getMessage(), $e->getCode(), $e);
}
public static function fromProblemDetails(ProblemDetailsExceptionInterface $e): self
{
return new self($e);
}
public function getStatus(): int
{
return $this->e->getStatus();
}
public function getType(): string
{
return $this->remapType($this->e->getType());
}
public function getTitle(): string
{
return $this->e->getTitle();
}
public function getDetail(): string
{
return $this->e->getDetail();
}
public function getAdditionalData(): array
{
return $this->e->getAdditionalData();
}
public function toArray(): array
{
return $this->remapTypeInArray($this->e->toArray());
}
public function jsonSerialize(): array
{
return $this->remapTypeInArray($this->e->jsonSerialize());
}
private function remapTypeInArray(array $wrappedArray): array
{
if (! isset($wrappedArray['type'])) {
return $wrappedArray;
}
return [...$wrappedArray, 'type' => $this->remapType($wrappedArray['type'])];
}
private function remapType(string $wrappedType): string
{
$segments = explode('/', $wrappedType);
$lastSegment = end($segments);
return match ($lastSegment) {
ValidationException::ERROR_CODE => 'INVALID_ARGUMENT',
DeleteShortUrlException::ERROR_CODE => 'INVALID_SHORT_URL_DELETION',
DomainNotFoundException::ERROR_CODE => 'DOMAIN_NOT_FOUND',
ForbiddenTagOperationException::ERROR_CODE => 'FORBIDDEN_OPERATION',
InvalidUrlException::ERROR_CODE => 'INVALID_URL',
NonUniqueSlugException::ERROR_CODE => 'INVALID_SLUG',
ShortUrlNotFoundException::ERROR_CODE => 'INVALID_SHORTCODE',
TagConflictException::ERROR_CODE => 'TAG_CONFLICT',
TagNotFoundException::ERROR_CODE => 'TAG_NOT_FOUND',
MercureException::ERROR_CODE => 'MERCURE_NOT_CONFIGURED',
MissingAuthenticationException::ERROR_CODE => 'INVALID_AUTHORIZATION',
VerifyAuthenticationException::ERROR_CODE => 'INVALID_API_KEY',
default => $wrappedType,
};
}
}

View file

@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Middleware\ErrorHandler;
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Rest\Exception\BackwardsCompatibleProblemDetailsException;
use function version_compare;
/** @deprecated */
class BackwardsCompatibleProblemDetailsHandler implements MiddlewareInterface
{
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
try {
return $handler->handle($request);
} catch (ProblemDetailsExceptionInterface $e) {
$version = $request->getAttribute('version') ?? '2';
throw version_compare($version, '3', '>=')
? $e
: BackwardsCompatibleProblemDetailsException::fromProblemDetails($e);
}
}
}

View file

@ -19,7 +19,7 @@ class CreateShortUrlTest extends ApiTestCase
#[Test]
public function createsNewShortUrlWhenOnlyLongUrlIsProvided(): void
{
$expectedKeys = ['shortCode', 'shortUrl', 'longUrl', 'dateCreated', 'visitsCount', 'tags'];
$expectedKeys = ['shortCode', 'shortUrl', 'longUrl', 'dateCreated', 'tags'];
[$statusCode, $payload] = $this->createShortUrl();
self::assertEquals(self::STATUS_OK, $statusCode);
@ -48,7 +48,7 @@ class CreateShortUrlTest extends ApiTestCase
self::assertEquals(self::STATUS_BAD_REQUEST, $statusCode);
self::assertEquals(self::STATUS_BAD_REQUEST, $payload['status']);
self::assertEquals($detail, $payload['detail']);
self::assertEquals('INVALID_SLUG', $payload['type']);
self::assertEquals('https://shlink.io/api/error/non-unique-slug', $payload['type']);
self::assertEquals('Invalid custom slug', $payload['title']);
self::assertEquals($slug, $payload['customSlug']);
@ -70,8 +70,8 @@ class CreateShortUrlTest extends ApiTestCase
public static function provideDuplicatedSlugApiVersions(): iterable
{
yield ['1', 'INVALID_SLUG'];
yield ['2', 'INVALID_SLUG'];
yield ['1', 'https://shlink.io/api/error/non-unique-slug'];
yield ['2', 'https://shlink.io/api/error/non-unique-slug'];
yield ['3', 'https://shlink.io/api/error/non-unique-slug'];
}
@ -241,7 +241,7 @@ class CreateShortUrlTest extends ApiTestCase
public static function provideInvalidUrls(): iterable
{
yield 'API version 2' => ['https://this-has-to-be-invalid.com', '2', 'INVALID_URL'];
yield 'API version 2' => ['https://this-has-to-be-invalid.com', '2', 'https://shlink.io/api/error/invalid-url'];
yield 'API version 3' => ['https://this-has-to-be-invalid.com', '3', 'https://shlink.io/api/error/invalid-url'];
}
@ -264,18 +264,18 @@ class CreateShortUrlTest extends ApiTestCase
public static function provideInvalidArgumentApiVersions(): iterable
{
yield 'missing long url v2' => [[], '2', 'INVALID_ARGUMENT'];
yield 'missing long url v2' => [[], '2', 'https://shlink.io/api/error/invalid-data'];
yield 'missing long url v3' => [[], '3', 'https://shlink.io/api/error/invalid-data'];
yield 'empty long url v2' => [['longUrl' => null], '2', 'INVALID_ARGUMENT'];
yield 'empty long url v2' => [['longUrl' => null], '2', 'https://shlink.io/api/error/invalid-data'];
yield 'empty long url v3' => [['longUrl' => ' '], '3', 'https://shlink.io/api/error/invalid-data'];
yield 'missing url schema v2' => [['longUrl' => 'foo.com'], '2', 'INVALID_ARGUMENT'];
yield 'missing url schema v2' => [['longUrl' => 'foo.com'], '2', 'https://shlink.io/api/error/invalid-data'];
yield 'missing url schema v3' => [['longUrl' => 'foo.com'], '3', 'https://shlink.io/api/error/invalid-data'];
yield 'empty device long url v2' => [[
'longUrl' => 'foo',
'deviceLongUrls' => [
'android' => null,
],
], '2', 'INVALID_ARGUMENT'];
], '2', 'https://shlink.io/api/error/invalid-data'];
yield 'empty device long url v3' => [[
'longUrl' => 'foo',
'deviceLongUrls' => [

View file

@ -31,7 +31,7 @@ class DeleteShortUrlTest extends ApiTestCase
self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode());
self::assertEquals(self::STATUS_NOT_FOUND, $payload['status']);
self::assertEquals('INVALID_SHORTCODE', $payload['type']);
self::assertEquals('https://shlink.io/api/error/short-url-not-found', $payload['type']);
self::assertEquals($expectedDetail, $payload['detail']);
self::assertEquals('Short URL not found', $payload['title']);
self::assertEquals($shortCode, $payload['shortCode']);
@ -52,8 +52,8 @@ class DeleteShortUrlTest extends ApiTestCase
public static function provideApiVersions(): iterable
{
yield ['1', 'INVALID_SHORTCODE'];
yield ['2', 'INVALID_SHORTCODE'];
yield ['1', 'https://shlink.io/api/error/short-url-not-found'];
yield ['2', 'https://shlink.io/api/error/short-url-not-found'];
yield ['3', 'https://shlink.io/api/error/short-url-not-found'];
}

View file

@ -30,8 +30,8 @@ class DeleteTagsTest extends ApiTestCase
public static function provideNonAdminApiKeys(): iterable
{
yield 'author' => ['author_api_key', '2', 'FORBIDDEN_OPERATION'];
yield 'domain' => ['domain_api_key', '2', 'FORBIDDEN_OPERATION'];
yield 'author' => ['author_api_key', '2', 'https://shlink.io/api/error/forbidden-tag-operation'];
yield 'domain' => ['domain_api_key', '2', 'https://shlink.io/api/error/forbidden-tag-operation'];
yield 'version 3' => ['domain_api_key', '3', 'https://shlink.io/api/error/forbidden-tag-operation'];
}
}

View file

@ -21,7 +21,7 @@ class DomainRedirectsTest extends ApiTestCase
self::assertEquals(self::STATUS_BAD_REQUEST, $resp->getStatusCode());
self::assertEquals(self::STATUS_BAD_REQUEST, $payload['status']);
self::assertEquals('INVALID_ARGUMENT', $payload['type']);
self::assertEquals('https://shlink.io/api/error/invalid-data', $payload['type']);
self::assertEquals('Provided data is not valid', $payload['detail']);
self::assertEquals('Invalid data', $payload['title']);
}

View file

@ -49,7 +49,7 @@ class DomainVisitsTest extends ApiTestCase
self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode());
self::assertEquals(self::STATUS_NOT_FOUND, $payload['status']);
self::assertEquals('DOMAIN_NOT_FOUND', $payload['type']);
self::assertEquals('https://shlink.io/api/error/domain-not-found', $payload['type']);
self::assertEquals(sprintf('Domain with authority "%s" could not be found', $domain), $payload['detail']);
self::assertEquals('Domain not found', $payload['title']);
self::assertEquals($domain, $payload['authority']);
@ -73,8 +73,8 @@ class DomainVisitsTest extends ApiTestCase
public static function provideApiVersions(): iterable
{
yield ['1', 'DOMAIN_NOT_FOUND'];
yield ['2', 'DOMAIN_NOT_FOUND'];
yield ['1', 'https://shlink.io/api/error/domain-not-found'];
yield ['2', 'https://shlink.io/api/error/domain-not-found'];
yield ['3', 'https://shlink.io/api/error/domain-not-found'];
}
}

View file

@ -96,7 +96,7 @@ class EditShortUrlTest extends ApiTestCase
public static function provideLongUrls(): iterable
{
yield 'valid URL' => ['https://shlink.io', self::STATUS_OK, null];
yield 'invalid URL' => ['http://foo', self::STATUS_BAD_REQUEST, 'INVALID_URL'];
yield 'invalid URL' => ['http://foo', self::STATUS_BAD_REQUEST, 'https://shlink.io/api/error/invalid-url'];
}
#[Test, DataProviderExternal(ApiTestDataProviders::class, 'invalidUrlsProvider')]
@ -112,7 +112,7 @@ class EditShortUrlTest extends ApiTestCase
self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode());
self::assertEquals(self::STATUS_NOT_FOUND, $payload['status']);
self::assertEquals('INVALID_SHORTCODE', $payload['type']);
self::assertEquals('https://shlink.io/api/error/short-url-not-found', $payload['type']);
self::assertEquals($expectedDetail, $payload['detail']);
self::assertEquals('Short URL not found', $payload['title']);
self::assertEquals($shortCode, $payload['shortCode']);
@ -131,7 +131,7 @@ class EditShortUrlTest extends ApiTestCase
self::assertEquals(self::STATUS_BAD_REQUEST, $resp->getStatusCode());
self::assertEquals(self::STATUS_BAD_REQUEST, $payload['status']);
self::assertEquals('INVALID_ARGUMENT', $payload['type']);
self::assertEquals('https://shlink.io/api/error/invalid-data', $payload['type']);
self::assertEquals($expectedDetail, $payload['detail']);
self::assertEquals('Invalid data', $payload['title']);
}

View file

@ -17,10 +17,8 @@ class GlobalVisitsTest extends ApiTestCase
$payload = $this->getJsonResponsePayload($resp);
self::assertArrayHasKey('visits', $payload);
self::assertArrayHasKey('visitsCount', $payload['visits']);
self::assertArrayHasKey('orphanVisitsCount', $payload['visits']);
self::assertEquals($expectedVisits, $payload['visits']['visitsCount']);
self::assertEquals($expectedOrphanVisits, $payload['visits']['orphanVisitsCount']);
self::assertEquals($expectedVisits, $payload['visits']['nonOrphanVisits']['total']);
self::assertEquals($expectedOrphanVisits, $payload['visits']['orphanVisits']['total']);
}
public static function provideApiKeys(): iterable

View file

@ -20,7 +20,6 @@ class ListShortUrlsTest extends ApiTestCase
'shortUrl' => 'http://s.test/abc123',
'longUrl' => 'https://shlink.io',
'dateCreated' => '2018-05-01T00:00:00+00:00',
'visitsCount' => 3,
'visitsSummary' => [
'total' => 3,
'nonBots' => 3,
@ -42,7 +41,6 @@ class ListShortUrlsTest extends ApiTestCase
'shortUrl' => 'http://s.test/ghi789',
'longUrl' => 'https://shlink.io/documentation/',
'dateCreated' => '2018-05-01T00:00:00+00:00',
'visitsCount' => 2,
'visitsSummary' => [
'total' => 2,
'nonBots' => 2,
@ -64,7 +62,6 @@ class ListShortUrlsTest extends ApiTestCase
'shortUrl' => 'http://some-domain.com/custom-with-domain',
'longUrl' => 'https://google.com',
'dateCreated' => '2018-10-20T00:00:00+00:00',
'visitsCount' => 0,
'visitsSummary' => [
'total' => 0,
'nonBots' => 0,
@ -88,7 +85,6 @@ class ListShortUrlsTest extends ApiTestCase
'https://blog.alejandrocelaya.com/2017/12/09'
. '/acmailer-7-0-the-most-important-release-in-a-long-time/',
'dateCreated' => '2019-01-01T00:00:10+00:00',
'visitsCount' => 2,
'visitsSummary' => [
'total' => 2,
'nonBots' => 1,
@ -110,7 +106,6 @@ class ListShortUrlsTest extends ApiTestCase
'shortUrl' => 'http://s.test/custom',
'longUrl' => 'https://shlink.io',
'dateCreated' => '2019-01-01T00:00:20+00:00',
'visitsCount' => 0,
'visitsSummary' => [
'total' => 0,
'nonBots' => 0,
@ -134,7 +129,6 @@ class ListShortUrlsTest extends ApiTestCase
'https://blog.alejandrocelaya.com/2019/04/27'
. '/considerations-to-properly-use-open-source-software-projects/',
'dateCreated' => '2019-01-01T00:00:30+00:00',
'visitsCount' => 0,
'visitsSummary' => [
'total' => 0,
'nonBots' => 0,
@ -310,7 +304,7 @@ class ListShortUrlsTest extends ApiTestCase
self::assertEquals([
'invalidElements' => $expectedInvalidElements,
'title' => 'Invalid data',
'type' => 'INVALID_ARGUMENT',
'type' => 'https://shlink.io/api/error/invalid-data',
'status' => 400,
'detail' => 'Provided data is not valid',
], $respPayload);

View file

@ -24,7 +24,7 @@ class RenameTagTest extends ApiTestCase
self::assertEquals(self::STATUS_FORBIDDEN, $resp->getStatusCode());
self::assertEquals(self::STATUS_FORBIDDEN, $payload['status']);
self::assertEquals('FORBIDDEN_OPERATION', $payload['type']);
self::assertEquals('https://shlink.io/api/error/forbidden-tag-operation', $payload['type']);
self::assertEquals('You are not allowed to rename tags', $payload['detail']);
self::assertEquals('Forbidden tag operation', $payload['title']);
}

View file

@ -58,7 +58,7 @@ class ResolveShortUrlTest extends ApiTestCase
self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode());
self::assertEquals(self::STATUS_NOT_FOUND, $payload['status']);
self::assertEquals('INVALID_SHORTCODE', $payload['type']);
self::assertEquals('https://shlink.io/api/error/short-url-not-found', $payload['type']);
self::assertEquals($expectedDetail, $payload['detail']);
self::assertEquals('Short URL not found', $payload['title']);
self::assertEquals($shortCode, $payload['shortCode']);

View file

@ -34,7 +34,7 @@ class ShortUrlVisitsTest extends ApiTestCase
self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode());
self::assertEquals(self::STATUS_NOT_FOUND, $payload['status']);
self::assertEquals('INVALID_SHORTCODE', $payload['type']);
self::assertEquals('https://shlink.io/api/error/short-url-not-found', $payload['type']);
self::assertEquals($expectedDetail, $payload['detail']);
self::assertEquals('Short URL not found', $payload['title']);
self::assertEquals($shortCode, $payload['shortCode']);

View file

@ -38,7 +38,7 @@ class SingleStepCreateShortUrlTest extends ApiTestCase
self::assertEquals(self::STATUS_UNAUTHORIZED, $resp->getStatusCode());
self::assertEquals(self::STATUS_UNAUTHORIZED, $payload['status']);
self::assertEquals('INVALID_AUTHORIZATION', $payload['type']);
self::assertEquals('https://shlink.io/api/error/missing-authentication', $payload['type']);
self::assertEquals($expectedDetail, $payload['detail']);
self::assertEquals('Invalid authorization', $payload['title']);
}

View file

@ -53,7 +53,7 @@ class TagVisitsTest extends ApiTestCase
self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode());
self::assertEquals(self::STATUS_NOT_FOUND, $payload['status']);
self::assertEquals('TAG_NOT_FOUND', $payload['type']);
self::assertEquals('https://shlink.io/api/error/tag-not-found', $payload['type']);
self::assertEquals(sprintf('Tag with name "%s" could not be found', $tag), $payload['detail']);
self::assertEquals('Tag not found', $payload['title']);
}

View file

@ -25,29 +25,12 @@ class TagsStatsTest extends ApiTestCase
self::assertEquals($expectedPagination, $tags['pagination']);
}
#[Test, DataProvider('provideQueries')]
public function expectedListOfTagsIsReturnedForDeprecatedApproach(
string $apiKey,
array $query,
array $expectedStats,
array $expectedPagination,
): void {
$query['withStats'] = 'true';
$resp = $this->callApiWithKey(self::METHOD_GET, '/tags', [RequestOptions::QUERY => $query], $apiKey);
['tags' => $tags] = $this->getJsonResponsePayload($resp);
self::assertEquals($expectedStats, $tags['stats']);
self::assertEquals($expectedPagination, $tags['pagination']);
self::assertArrayHasKey('data', $tags);
}
public static function provideQueries(): iterable
{
yield 'admin API key' => ['valid_api_key', [], [
[
'tag' => 'bar',
'shortUrlsCount' => 1,
'visitsCount' => 2,
'visitsSummary' => [
'total' => 2,
'nonBots' => 1,
@ -57,7 +40,6 @@ class TagsStatsTest extends ApiTestCase
[
'tag' => 'baz',
'shortUrlsCount' => 0,
'visitsCount' => 0,
'visitsSummary' => [
'total' => 0,
'nonBots' => 0,
@ -67,7 +49,6 @@ class TagsStatsTest extends ApiTestCase
[
'tag' => 'foo',
'shortUrlsCount' => 3,
'visitsCount' => 5,
'visitsSummary' => [
'total' => 5,
'nonBots' => 4,
@ -85,7 +66,6 @@ class TagsStatsTest extends ApiTestCase
[
'tag' => 'bar',
'shortUrlsCount' => 1,
'visitsCount' => 2,
'visitsSummary' => [
'total' => 2,
'nonBots' => 1,
@ -95,7 +75,6 @@ class TagsStatsTest extends ApiTestCase
[
'tag' => 'baz',
'shortUrlsCount' => 0,
'visitsCount' => 0,
'visitsSummary' => [
'total' => 0,
'nonBots' => 0,
@ -113,7 +92,6 @@ class TagsStatsTest extends ApiTestCase
[
'tag' => 'bar',
'shortUrlsCount' => 1,
'visitsCount' => 2,
'visitsSummary' => [
'total' => 2,
'nonBots' => 1,
@ -123,7 +101,6 @@ class TagsStatsTest extends ApiTestCase
[
'tag' => 'foo',
'shortUrlsCount' => 2,
'visitsCount' => 5,
'visitsSummary' => [
'total' => 5,
'nonBots' => 4,
@ -141,7 +118,6 @@ class TagsStatsTest extends ApiTestCase
[
'tag' => 'foo',
'shortUrlsCount' => 2,
'visitsCount' => 5,
'visitsSummary' => [
'total' => 5,
'nonBots' => 4,
@ -159,7 +135,6 @@ class TagsStatsTest extends ApiTestCase
[
'tag' => 'foo',
'shortUrlsCount' => 1,
'visitsCount' => 0,
'visitsSummary' => [
'total' => 0,
'nonBots' => 0,

View file

@ -23,7 +23,7 @@ class UpdateTagTest extends ApiTestCase
self::assertEquals(self::STATUS_BAD_REQUEST, $resp->getStatusCode());
self::assertEquals(self::STATUS_BAD_REQUEST, $payload['status']);
self::assertEquals('INVALID_ARGUMENT', $payload['type']);
self::assertEquals('https://shlink.io/api/error/invalid-data', $payload['type']);
self::assertEquals($expectedDetail, $payload['detail']);
self::assertEquals('Invalid data', $payload['title']);
}
@ -55,8 +55,8 @@ class UpdateTagTest extends ApiTestCase
public static function provideTagNotFoundApiVersions(): iterable
{
yield 'version 1' => ['1', 'TAG_NOT_FOUND'];
yield 'version 2' => ['2', 'TAG_NOT_FOUND'];
yield 'version 1' => ['1', 'https://shlink.io/api/error/tag-not-found'];
yield 'version 2' => ['2', 'https://shlink.io/api/error/tag-not-found'];
yield 'version 3' => ['3', 'https://shlink.io/api/error/tag-not-found'];
}
@ -80,8 +80,8 @@ class UpdateTagTest extends ApiTestCase
public static function provideTagConflictsApiVersions(): iterable
{
yield 'version 1' => ['1', 'TAG_CONFLICT'];
yield 'version 2' => ['2', 'TAG_CONFLICT'];
yield 'version 1' => ['1', 'https://shlink.io/api/error/tag-conflict'];
yield 'version 2' => ['2', 'https://shlink.io/api/error/tag-conflict'];
yield 'version 3' => ['3', 'https://shlink.io/api/error/tag-conflict'];
}

View file

@ -32,8 +32,6 @@ class VisitStatsTest extends ApiTestCase
'nonBots' => 2,
'bots' => 1,
],
'visitsCount' => 7,
'orphanVisitsCount' => 3,
]];
yield 'domain-only API key' => ['domain_api_key', [
'nonOrphanVisits' => [
@ -46,8 +44,6 @@ class VisitStatsTest extends ApiTestCase
'nonBots' => 2,
'bots' => 1,
],
'visitsCount' => 0,
'orphanVisitsCount' => 3,
]];
yield 'author API key' => ['author_api_key', [
'nonOrphanVisits' => [
@ -60,8 +56,6 @@ class VisitStatsTest extends ApiTestCase
'nonBots' => 2,
'bots' => 1,
],
'visitsCount' => 5,
'orphanVisitsCount' => 3,
]];
}
}

View file

@ -29,8 +29,8 @@ class AuthenticationTest extends ApiTestCase
public static function provideApiVersions(): iterable
{
yield 'version 1' => ['1', 'INVALID_AUTHORIZATION'];
yield 'version 2' => ['2', 'INVALID_AUTHORIZATION'];
yield 'version 1' => ['1', 'https://shlink.io/api/error/missing-authentication'];
yield 'version 2' => ['2', 'https://shlink.io/api/error/missing-authentication'];
yield 'version 3' => ['3', 'https://shlink.io/api/error/missing-authentication'];
}
@ -58,9 +58,9 @@ class AuthenticationTest extends ApiTestCase
public static function provideInvalidApiKeys(): iterable
{
yield 'key which does not exist' => ['invalid', '2', 'INVALID_API_KEY'];
yield 'key which is expired' => ['expired_api_key', '2', 'INVALID_API_KEY'];
yield 'key which is disabled' => ['disabled_api_key', '2', 'INVALID_API_KEY'];
yield 'key which does not exist' => ['invalid', '2', 'https://shlink.io/api/error/invalid-api-key'];
yield 'key which is expired' => ['expired_api_key', '2', 'https://shlink.io/api/error/invalid-api-key'];
yield 'key which is disabled' => ['disabled_api_key', '2', 'https://shlink.io/api/error/invalid-api-key'];
yield 'version 3' => ['disabled_api_key', '3', 'https://shlink.io/api/error/invalid-api-key'];
}
}

View file

@ -7,14 +7,12 @@ namespace ShlinkioTest\Shlink\Rest\Action\Tag;
use Laminas\Diactoros\Response\JsonResponse;
use Laminas\Diactoros\ServerRequestFactory;
use Pagerfanta\Adapter\ArrayAdapter;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\Tag\Entity\Tag;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Shlinkio\Shlink\Rest\Action\Tag\ListTagsAction;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
@ -32,8 +30,8 @@ class ListTagsActionTest extends TestCase
$this->action = new ListTagsAction($this->tagService);
}
#[Test, DataProvider('provideNoStatsQueries')]
public function returnsBaseDataWhenStatsAreNotRequested(array $query): void
#[Test]
public function returnsBaseDataWhenStatsAreNotRequested(): void
{
$tags = [new Tag('foo'), new Tag('bar')];
$tagsCount = count($tags);
@ -43,7 +41,7 @@ class ListTagsActionTest extends TestCase
)->willReturn(new Paginator(new ArrayAdapter($tags)));
/** @var JsonResponse $resp */
$resp = $this->action->handle($this->requestWithApiKey()->withQueryParams($query));
$resp = $this->action->handle($this->requestWithApiKey());
$payload = $resp->getPayload();
self::assertEquals([
@ -60,46 +58,6 @@ class ListTagsActionTest extends TestCase
], $payload);
}
public static function provideNoStatsQueries(): iterable
{
yield 'no query' => [[]];
yield 'withStats is false' => [['withStats' => 'withStats']];
yield 'withStats is something else' => [['withStats' => 'foo']];
}
#[Test]
public function returnsStatsWhenRequested(): void
{
$stats = [
new TagInfo('foo', 1, 1),
new TagInfo('bar', 3, 10),
];
$itemsCount = count($stats);
$this->tagService->expects($this->once())->method('tagsInfo')->with(
$this->anything(),
$this->isInstanceOf(ApiKey::class),
)->willReturn(new Paginator(new ArrayAdapter($stats)));
$req = $this->requestWithApiKey()->withQueryParams(['withStats' => 'true']);
/** @var JsonResponse $resp */
$resp = $this->action->handle($req);
$payload = $resp->getPayload();
self::assertEquals([
'tags' => [
'data' => ['foo', 'bar'],
'stats' => $stats,
'pagination' => [
'currentPage' => 1,
'pagesCount' => 1,
'itemsPerPage' => 10,
'itemsInCurrentPage' => $itemsCount,
'totalItems' => $itemsCount,
],
],
], $payload);
}
private function requestWithApiKey(): ServerRequestInterface
{
return ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, ApiKey::create());

View file

@ -1,113 +0,0 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Rest\Exception;
use Exception;
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Exception\DeleteShortUrlException;
use Shlinkio\Shlink\Core\Exception\DomainNotFoundException;
use Shlinkio\Shlink\Core\Exception\ForbiddenTagOperationException;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Exception\TagConflictException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Rest\Exception\BackwardsCompatibleProblemDetailsException;
use Shlinkio\Shlink\Rest\Exception\MercureException;
use Shlinkio\Shlink\Rest\Exception\MissingAuthenticationException;
use Shlinkio\Shlink\Rest\Exception\VerifyAuthenticationException;
class BackwardsCompatibleProblemDetailsExceptionTest extends TestCase
{
#[Test, DataProvider('provideTypes')]
public function typeIsRemappedOnWrappedException(
string $wrappedType,
string $expectedType,
bool $expectSameType = false,
): void {
$original = new class ($wrappedType) extends Exception implements ProblemDetailsExceptionInterface {
public function __construct(private readonly string $type)
{
parent::__construct('');
}
public function getStatus(): int
{
return 123;
}
public function getType(): string
{
return $this->type;
}
public function getTitle(): string
{
return 'title';
}
public function getDetail(): string
{
return 'detail';
}
public function getAdditionalData(): array
{
return [];
}
public function toArray(): array
{
return ['type' => $this->type];
}
public function jsonSerialize(): array
{
return ['type' => $this->type];
}
};
$e = BackwardsCompatibleProblemDetailsException::fromProblemDetails($original);
self::assertEquals($e->getType(), $expectedType);
self::assertEquals($e->toArray(), ['type' => $expectedType]);
self::assertEquals($e->jsonSerialize(), ['type' => $expectedType]);
self::assertEquals($original->getTitle(), $e->getTitle());
self::assertEquals($original->getDetail(), $e->getDetail());
self::assertEquals($original->getAdditionalData(), $e->getAdditionalData());
if ($expectSameType) {
self::assertEquals($original->getType(), $e->getType());
self::assertEquals($original->toArray(), $e->toArray());
self::assertEquals($original->jsonSerialize(), $e->jsonSerialize());
} else {
self::assertNotEquals($original->getType(), $e->getType());
self::assertNotEquals($original->toArray(), $e->toArray());
self::assertNotEquals($original->jsonSerialize(), $e->jsonSerialize());
}
}
public static function provideTypes(): iterable
{
yield ['foo', 'foo', true];
yield ['bar', 'bar', true];
yield [ValidationException::ERROR_CODE, 'INVALID_ARGUMENT'];
yield [DeleteShortUrlException::ERROR_CODE, 'INVALID_SHORT_URL_DELETION'];
yield [DomainNotFoundException::ERROR_CODE, 'DOMAIN_NOT_FOUND'];
yield [ForbiddenTagOperationException::ERROR_CODE, 'FORBIDDEN_OPERATION'];
yield [InvalidUrlException::ERROR_CODE, 'INVALID_URL'];
yield [NonUniqueSlugException::ERROR_CODE, 'INVALID_SLUG'];
yield [ShortUrlNotFoundException::ERROR_CODE, 'INVALID_SHORTCODE'];
yield [TagConflictException::ERROR_CODE, 'TAG_CONFLICT'];
yield [TagNotFoundException::ERROR_CODE, 'TAG_NOT_FOUND'];
yield [MercureException::ERROR_CODE, 'MERCURE_NOT_CONFIGURED'];
yield [MissingAuthenticationException::ERROR_CODE, 'INVALID_AUTHORIZATION'];
yield [VerifyAuthenticationException::ERROR_CODE, 'INVALID_API_KEY'];
}
}

View file

@ -1,74 +0,0 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Rest\Middleware\ErrorHandler;
use Laminas\Diactoros\ServerRequestFactory;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Rest\Exception\BackwardsCompatibleProblemDetailsException;
use Shlinkio\Shlink\Rest\Middleware\ErrorHandler\BackwardsCompatibleProblemDetailsHandler;
use Throwable;
class BackwardsCompatibleProblemDetailsHandlerTest extends TestCase
{
private BackwardsCompatibleProblemDetailsHandler $handler;
protected function setUp(): void
{
$this->handler = new BackwardsCompatibleProblemDetailsHandler();
}
/**
* @param class-string<Throwable> $expectedException
*/
#[Test, DataProvider('provideExceptions')]
public function expectedExceptionIsThrownBasedOnTheRequestVersion(
ServerRequestInterface $request,
Throwable $thrownException,
string $expectedException,
): void {
$handler = $this->createMock(RequestHandlerInterface::class);
$handler->expects($this->once())->method('handle')->with($request)->willThrowException($thrownException);
$this->expectException($expectedException);
$this->handler->process($request, $handler);
}
public static function provideExceptions(): iterable
{
$baseRequest = ServerRequestFactory::fromGlobals();
yield 'no version' => [
$baseRequest,
ValidationException::fromArray([]),
BackwardsCompatibleProblemDetailsException::class,
];
yield 'version 1' => [
$baseRequest->withAttribute('version', '1'),
ValidationException::fromArray([]),
BackwardsCompatibleProblemDetailsException::class,
];
yield 'version 2' => [
$baseRequest->withAttribute('version', '2'),
ValidationException::fromArray([]),
BackwardsCompatibleProblemDetailsException::class,
];
yield 'version 3' => [
$baseRequest->withAttribute('version', '3'),
ValidationException::fromArray([]),
ValidationException::class,
];
yield 'version 4' => [
$baseRequest->withAttribute('version', '3'),
ValidationException::fromArray([]),
ValidationException::class,
];
}
}