Add a 3-second timeout to title resolution

This commit is contained in:
Alejandro Celaya 2024-02-17 12:13:05 +01:00
commit d3bfd99210
29 changed files with 218 additions and 624 deletions

View file

@ -13,6 +13,7 @@
* 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.
* Shlink no longer allows to opt-in for long URL verification. Long URLs are unconditionally considered correct during short URL creation/edition.
### Changes in REST API
@ -21,7 +22,6 @@
* `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`

View file

@ -42,12 +42,12 @@
"pugx/shortid-php": "^1.1",
"ramsey/uuid": "^4.7",
"shlinkio/doctrine-specification": "^2.1.1",
"shlinkio/shlink-common": "dev-main#178b332 as 6.0",
"shlinkio/shlink-config": "dev-main#6b287b3 as 2.6",
"shlinkio/shlink-event-dispatcher": "dev-main#46f5e21 as 4.0",
"shlinkio/shlink-importer": "dev-main#f0a1f1d as 5.3",
"shlinkio/shlink-installer": "dev-develop#2dee7db as 9.0",
"shlinkio/shlink-ip-geolocation": "dev-main#c123a52 as 3.5",
"shlinkio/shlink-common": "dev-main#762b3b8 as 6.0",
"shlinkio/shlink-config": "dev-main#a43b380 as 3.0",
"shlinkio/shlink-event-dispatcher": "dev-main#aa9023c as 4.0",
"shlinkio/shlink-importer": "dev-main#65a9a30 as 5.3",
"shlinkio/shlink-installer": "dev-develop#3e8d7d7 as 9.0",
"shlinkio/shlink-ip-geolocation": "dev-main#a807668 as 3.5",
"shlinkio/shlink-json": "^1.1",
"spiral/roadrunner": "^2023.3",
"spiral/roadrunner-cli": "^2.6",

View file

@ -20,11 +20,6 @@
"description": "The maximum number of allowed visits for this short code",
"type": ["number", "null"]
},
"validateUrl": {
"deprecated": true,
"description": "**[DEPRECATED]** Tells if the long URL should or should not be validated as a reachable URL. Defaults to `false`",
"type": "boolean"
},
"tags": {
"type": "array",
"items": {

View file

@ -388,10 +388,6 @@
]
}
},
"url": {
"type": "string",
"description": "A URL that could not be verified, if the error type is https://shlink.io/api/error/invalid-url"
},
"customSlug": {
"type": "string",
"description": "Provided custom slug when the error type is https://shlink.io/api/error/non-unique-slug"
@ -408,15 +404,6 @@
"Invalid arguments with API v3 and newer": {
"$ref": "../examples/short-url-invalid-args-v3.json"
},
"Invalid long URL with API v3 and newer": {
"value": {
"title": "Invalid URL",
"type": "https://shlink.io/api/error/invalid-url",
"detail": "Provided URL foo is invalid. Try with a different one.",
"status": 400,
"url": "https://invalid-url.com"
}
},
"Non-unique slug with API v3 and newer": {
"value": {
"title": "Invalid custom slug",
@ -429,15 +416,6 @@
"Invalid arguments previous to API v3": {
"$ref": "../examples/short-url-invalid-args-v2.json"
},
"Invalid long URL previous to API v3": {
"value": {
"title": "Invalid URL",
"type": "INVALID_URL",
"detail": "Provided URL foo is invalid. Try with a different one.",
"status": 400,
"url": "https://invalid-url.com"
}
},
"Non-unique slug previous to API v3": {
"value": {
"title": "Invalid custom slug",

View file

@ -88,49 +88,6 @@
}
}
},
"400": {
"description": "The long URL was not provided or is invalid.",
"content": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
},
"examples": {
"API v3 and newer": {
"value": {
"title": "Invalid URL",
"type": "https://shlink.io/api/error/invalid-url",
"detail": "Provided URL foo is invalid. Try with a different one.",
"status": 400,
"url": "https://invalid-url.com"
}
},
"Previous to API v3": {
"value": {
"title": "Invalid URL",
"type": "INVALID_URL",
"detail": "Provided URL foo is invalid. Try with a different one.",
"status": 400,
"url": "https://invalid-url.com"
}
}
}
},
"text/plain": {
"schema": {
"type": "string"
},
"examples": {
"API v3 and newer": {
"value": "https://shlink.io/api/error/invalid-url"
},
"Previous to API v3": {
"value": "INVALID_URL"
}
}
}
}
},
"default": {
"description": "Unexpected error.",
"content": {

View file

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
@ -95,12 +94,6 @@ class CreateShortUrlCommand extends Command
InputOption::VALUE_REQUIRED,
'The length for generated short code (it will be ignored if --custom-slug was provided).',
)
->addOption(
'validate-url',
null,
InputOption::VALUE_NONE,
'[DEPRECATED] Makes the URL to be validated as publicly accessible.',
)
->addOption(
'crawlable',
'r',
@ -148,7 +141,6 @@ class CreateShortUrlCommand extends Command
$customSlug = $input->getOption('custom-slug');
$maxVisits = $input->getOption('max-visits');
$shortCodeLength = $input->getOption('short-code-length') ?? $this->options->defaultShortCodesLength;
$doValidateUrl = $input->getOption('validate-url');
try {
$result = $this->urlShortener->shorten(ShortUrlCreation::fromRawData([
@ -160,7 +152,6 @@ class CreateShortUrlCommand extends Command
ShortUrlInputFilter::FIND_IF_EXISTS => $input->getOption('find-if-exists'),
ShortUrlInputFilter::DOMAIN => $input->getOption('domain'),
ShortUrlInputFilter::SHORT_CODE_LENGTH => $shortCodeLength,
ShortUrlInputFilter::VALIDATE_URL => $doValidateUrl,
ShortUrlInputFilter::TAGS => $tags,
ShortUrlInputFilter::CRAWLABLE => $input->getOption('crawlable'),
ShortUrlInputFilter::FORWARD_QUERY => !$input->getOption('no-forward-query'),
@ -176,7 +167,7 @@ class CreateShortUrlCommand extends Command
sprintf('Generated short URL: <info>%s</info>', $this->stringifier->stringify($result->shortUrl)),
]);
return ExitCode::EXIT_SUCCESS;
} catch (InvalidUrlException | NonUniqueSlugException $e) {
} catch (NonUniqueSlugException $e) {
$io->error($e->getMessage());
return ExitCode::EXIT_FAILURE;
}

View file

@ -12,7 +12,6 @@ use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\ShortUrl\CreateShortUrlCommand;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
@ -68,22 +67,6 @@ class CreateShortUrlCommandTest extends TestCase
self::assertStringNotContainsString('but the real-time updates cannot', $output);
}
#[Test]
public function exceptionWhileParsingLongUrlOutputsError(): void
{
$url = 'http://domain.com/invalid';
$this->urlShortener->expects($this->once())->method('shorten')->withAnyParameters()->willThrowException(
InvalidUrlException::fromUrl($url),
);
$this->stringifier->method('stringify')->with($this->isInstanceOf(ShortUrl::class))->willReturn('');
$this->commandTester->execute(['longUrl' => $url]);
$output = $this->commandTester->getDisplay();
self::assertEquals(ExitCode::EXIT_FAILURE, $this->commandTester->getStatusCode());
self::assertStringContainsString('Provided URL http://domain.com/invalid is invalid.', $output);
}
#[Test]
public function providingNonUniqueSlugOutputsError(): void
{
@ -148,12 +131,12 @@ class CreateShortUrlCommandTest extends TestCase
}
#[Test, DataProvider('provideFlags')]
public function urlValidationHasExpectedValueBasedOnProvidedFlags(array $options, ?bool $expectedValidateUrl): void
public function urlValidationHasExpectedValueBasedOnProvidedFlags(array $options, ?bool $expectedCrawlable): void
{
$shortUrl = ShortUrl::createFake();
$this->urlShortener->expects($this->once())->method('shorten')->with(
$this->callback(function (ShortUrlCreation $meta) use ($expectedValidateUrl) {
Assert::assertEquals($expectedValidateUrl, $meta->doValidateUrl());
$this->callback(function (ShortUrlCreation $meta) use ($expectedCrawlable) {
Assert::assertEquals($expectedCrawlable, $meta->crawlable);
return true;
}),
)->willReturn(UrlShorteningResult::withoutErrorOnEventDispatching($shortUrl));
@ -166,7 +149,7 @@ class CreateShortUrlCommandTest extends TestCase
public static function provideFlags(): iterable
{
yield 'no flags' => [[], null];
yield 'validate-url' => [['--validate-url' => true], true];
yield 'crawlable' => [['--crawlable' => true], true];
}
/**

View file

@ -75,7 +75,6 @@ return [
Visit\Entity\Visit::class,
],
Util\UrlValidator::class => ConfigAbstractFactory::class,
Util\DoctrineBatchHelper::class => ConfigAbstractFactory::class,
Util\RedirectResponseHelper::class => ConfigAbstractFactory::class,
@ -153,7 +152,6 @@ return [
ShortUrl\Helper\ShortCodeUniquenessHelper::class => ['em', Options\UrlShortenerOptions::class],
Domain\DomainService::class => ['em', 'config.url_shortener.domain.hostname'],
Util\UrlValidator::class => ['httpClient', Options\UrlShortenerOptions::class],
Util\DoctrineBatchHelper::class => ['em'],
Util\RedirectResponseHelper::class => [Options\RedirectOptions::class],
@ -180,7 +178,7 @@ return [
Lock\LockFactory::class,
],
ShortUrl\Helper\ShortUrlStringifier::class => ['config.url_shortener.domain', 'config.router.base_path'],
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => [Util\UrlValidator::class],
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => ['httpClient', Options\UrlShortenerOptions::class],
ShortUrl\Helper\ShortUrlRedirectionBuilder::class => [Options\TrackingOptions::class],
ShortUrl\Transformer\ShortUrlDataTransformer::class => [ShortUrl\Helper\ShortUrlStringifier::class],
ShortUrl\Middleware\ExtraPathRedirectMiddleware::class => [

View file

@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Exception;
use Fig\Http\Message\StatusCodeInterface;
use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
use Throwable;
use function Shlinkio\Shlink\Core\toProblemDetailsType;
use function sprintf;
class InvalidUrlException extends DomainException implements ProblemDetailsExceptionInterface
{
use CommonProblemDetailsExceptionTrait;
private const TITLE = 'Invalid URL';
public const ERROR_CODE = 'invalid-url';
public static function fromUrl(string $url, ?Throwable $previous = null): self
{
$status = StatusCodeInterface::STATUS_BAD_REQUEST;
$e = new self(sprintf('Provided URL %s is invalid. Try with a different one.', $url), $status, $previous);
$e->detail = $e->getMessage();
$e->title = self::TITLE;
$e->type = toProblemDetailsType(self::ERROR_CODE);
$e->status = $status;
$e->additional = ['url' => $url];
return $e;
}
}

View file

@ -120,7 +120,6 @@ class ShortUrl extends AbstractEntity
?ShortUrlRelationResolverInterface $relationResolver = null,
): self {
$meta = [
ShortUrlInputFilter::VALIDATE_URL => false,
ShortUrlInputFilter::LONG_URL => $url->longUrl,
ShortUrlInputFilter::DOMAIN => $url->domain,
ShortUrlInputFilter::TAGS => $url->tags,

View file

@ -4,31 +4,92 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Helper;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Util\UrlValidatorInterface;
use Fig\Http\Message\RequestMethodInterface;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\RequestOptions;
use Psr\Http\Message\ResponseInterface;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Throwable;
class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionHelperInterface
use function html_entity_decode;
use function preg_match;
use function str_contains;
use function str_starts_with;
use function strtolower;
use function trim;
use const Shlinkio\Shlink\TITLE_TAG_VALUE;
readonly class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionHelperInterface
{
public function __construct(private readonly UrlValidatorInterface $urlValidator)
{
public const MAX_REDIRECTS = 15;
public const CHROME_USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) '
. 'Chrome/121.0.0.0 Safari/537.36';
public function __construct(
private ClientInterface $httpClient,
private UrlShortenerOptions $options,
) {
}
/**
* @deprecated TODO Rename to processTitle once URL validation is removed with Shlink 4.0.0
* Move relevant logic from URL validator here.
* @template T of TitleResolutionModelInterface
* @param T $data
* @return T
* @throws InvalidUrlException
*/
public function processTitleAndValidateUrl(TitleResolutionModelInterface $data): TitleResolutionModelInterface
public function processTitle(TitleResolutionModelInterface $data): TitleResolutionModelInterface
{
if ($data->hasTitle()) {
$this->urlValidator->validateUrl($data->getLongUrl(), $data->doValidateUrl());
if (! $this->options->autoResolveTitles || $data->hasTitle()) {
return $data;
}
$title = $this->urlValidator->validateUrlWithTitle($data->getLongUrl(), $data->doValidateUrl());
return $title === null ? $data : $data->withResolvedTitle($title);
$response = $this->fetchUrl($data->getLongUrl());
if ($response === null) {
return $data;
}
$contentType = strtolower($response->getHeaderLine('Content-Type'));
if (! str_starts_with($contentType, 'text/html')) {
return $data;
}
$title = $this->tryToResolveTitle($response);
return $title !== null ? $data->withResolvedTitle($title) : $data;
}
private function fetchUrl(string $url): ?ResponseInterface
{
try {
return $this->httpClient->request(RequestMethodInterface::METHOD_GET, $url, [
// Add a sensible 3-second timeout that prevents hanging here forever
RequestOptions::TIMEOUT => 3,
RequestOptions::CONNECT_TIMEOUT => 3,
// Prevent potential infinite redirection loops
RequestOptions::ALLOW_REDIRECTS => ['max' => self::MAX_REDIRECTS],
RequestOptions::IDN_CONVERSION => true,
// Making the request with a browser's user agent results in responses closer to a real user
RequestOptions::HEADERS => ['User-Agent' => self::CHROME_USER_AGENT],
RequestOptions::STREAM => true, // This ensures large files are not fully downloaded if not needed
]);
} catch (Throwable) {
return null;
}
}
private function tryToResolveTitle(ResponseInterface $response): ?string
{
$collectedBody = '';
$body = $response->getBody();
// With streaming enabled, we can walk the body until the </title> tag is found, and then stop
while (! str_contains($collectedBody, '</title>') && ! $body->eof()) {
$collectedBody .= $body->read(1024);
}
preg_match(TITLE_TAG_VALUE, $collectedBody, $matches);
return isset($matches[1]) ? $this->normalizeTitle($matches[1]) : null;
}
private function normalizeTitle(string $title): string
{
return html_entity_decode(trim($title));
}
}

View file

@ -4,16 +4,12 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Helper;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
interface ShortUrlTitleResolutionHelperInterface
{
/**
* @deprecated TODO Rename to processTitle once URL validation is removed with Shlink 4.0.0
* @template T of TitleResolutionModelInterface
* @param T $data
* @return T
* @throws InvalidUrlException
*/
public function processTitleAndValidateUrl(TitleResolutionModelInterface $data): TitleResolutionModelInterface;
public function processTitle(TitleResolutionModelInterface $data): TitleResolutionModelInterface;
}

View file

@ -10,8 +10,5 @@ interface TitleResolutionModelInterface
public function getLongUrl(): string;
/** @deprecated */
public function doValidateUrl(): bool;
public function withResolvedTitle(string $title): static;
}

View file

@ -35,8 +35,6 @@ final class ShortUrlCreation implements TitleResolutionModelInterface
public readonly bool $findIfExists = false,
public readonly ?string $domain = null,
public readonly int $shortCodeLength = 5,
/** @deprecated */
public readonly bool $validateUrl = false,
public readonly ?ApiKey $apiKey = null,
public readonly array $tags = [],
public readonly ?string $title = null,
@ -75,7 +73,6 @@ final class ShortUrlCreation implements TitleResolutionModelInterface
$inputFilter,
ShortUrlInputFilter::SHORT_CODE_LENGTH,
) ?? DEFAULT_SHORT_CODES_LENGTH,
validateUrl: getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::VALIDATE_URL) ?? false,
apiKey: $inputFilter->getValue(ShortUrlInputFilter::API_KEY),
tags: $inputFilter->getValue(ShortUrlInputFilter::TAGS),
title: $inputFilter->getValue(ShortUrlInputFilter::TITLE),
@ -97,7 +94,6 @@ final class ShortUrlCreation implements TitleResolutionModelInterface
findIfExists: $this->findIfExists,
domain: $this->domain,
shortCodeLength: $this->shortCodeLength,
validateUrl: $this->validateUrl,
apiKey: $this->apiKey,
tags: $this->tags,
title: $title,
@ -137,12 +133,6 @@ final class ShortUrlCreation implements TitleResolutionModelInterface
return $this->domain !== null;
}
/** @deprecated */
public function doValidateUrl(): bool
{
return $this->validateUrl;
}
public function hasTitle(): bool
{
return $this->title !== null;

View file

@ -38,8 +38,6 @@ final class ShortUrlEdition implements TitleResolutionModelInterface
private readonly bool $titlePropWasProvided = false,
public readonly ?string $title = null,
public readonly bool $titleWasAutoResolved = false,
/** @deprecated */
public readonly bool $validateUrl = false,
private readonly bool $crawlablePropWasProvided = false,
public readonly bool $crawlable = false,
private readonly bool $forwardQueryPropWasProvided = false,
@ -76,7 +74,6 @@ final class ShortUrlEdition implements TitleResolutionModelInterface
tags: $inputFilter->getValue(ShortUrlInputFilter::TAGS),
titlePropWasProvided: array_key_exists(ShortUrlInputFilter::TITLE, $data),
title: $inputFilter->getValue(ShortUrlInputFilter::TITLE),
validateUrl: getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::VALIDATE_URL) ?? false,
crawlablePropWasProvided: array_key_exists(ShortUrlInputFilter::CRAWLABLE, $data),
crawlable: $inputFilter->getValue(ShortUrlInputFilter::CRAWLABLE),
forwardQueryPropWasProvided: array_key_exists(ShortUrlInputFilter::FORWARD_QUERY, $data),
@ -102,7 +99,6 @@ final class ShortUrlEdition implements TitleResolutionModelInterface
titlePropWasProvided: $this->titlePropWasProvided,
title: $title,
titleWasAutoResolved: true,
validateUrl: $this->validateUrl,
crawlablePropWasProvided: $this->crawlablePropWasProvided,
crawlable: $this->crawlable,
forwardQueryPropWasProvided: $this->forwardQueryPropWasProvided,
@ -155,12 +151,6 @@ final class ShortUrlEdition implements TitleResolutionModelInterface
return $this->titleWasAutoResolved;
}
/** @deprecated */
public function doValidateUrl(): bool
{
return $this->validateUrl;
}
public function crawlableWasProvided(): bool
{
return $this->crawlablePropWasProvided;

View file

@ -36,8 +36,6 @@ class ShortUrlInputFilter extends InputFilter
public const SHORT_CODE_LENGTH = 'shortCodeLength';
public const LONG_URL = 'longUrl';
public const DEVICE_LONG_URLS = 'deviceLongUrls';
/** @deprecated */
public const VALIDATE_URL = 'validateUrl';
public const API_KEY = 'apiKey';
public const TAGS = 'tags';
public const TITLE = 'title';
@ -97,9 +95,8 @@ class ShortUrlInputFilter extends InputFilter
$this->add($this->createBooleanInput(self::FIND_IF_EXISTS, false));
// These cannot be defined as a boolean inputs, because they can actually have 3 values: true, false and null.
// This cannot be defined as a boolean inputs, because they can actually have 3 values: true, false and null.
// Defining them as boolean will make null fall back to false, which is not the desired behavior.
$this->add($this->createInput(self::VALIDATE_URL, false));
$this->add($this->createInput(self::FORWARD_QUERY, false));
$domain = $this->createInput(self::DOMAIN, false);

View file

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl;
use Doctrine\ORM;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlTitleResolutionHelperInterface;
@ -26,7 +25,6 @@ class ShortUrlService implements ShortUrlServiceInterface
/**
* @throws ShortUrlNotFoundException
* @throws InvalidUrlException
*/
public function updateShortUrl(
ShortUrlIdentifier $identifier,
@ -34,7 +32,7 @@ class ShortUrlService implements ShortUrlServiceInterface
?ApiKey $apiKey = null,
): ShortUrl {
if ($shortUrlEdit->longUrlWasProvided()) {
$shortUrlEdit = $this->titleResolutionHelper->processTitleAndValidateUrl($shortUrlEdit);
$shortUrlEdit = $this->titleResolutionHelper->processTitle($shortUrlEdit);
}
$shortUrl = $this->urlResolver->resolveShortUrl($identifier, $apiKey);

View file

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition;
@ -15,7 +14,6 @@ interface ShortUrlServiceInterface
{
/**
* @throws ShortUrlNotFoundException
* @throws InvalidUrlException
*/
public function updateShortUrl(
ShortUrlIdentifier $identifier,

View file

@ -8,7 +8,6 @@ use Doctrine\ORM\EntityManagerInterface;
use Psr\Container\ContainerExceptionInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Shlinkio\Shlink\Core\EventDispatcher\Event\ShortUrlCreated;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortCodeUniquenessHelperInterface;
@ -31,7 +30,6 @@ class UrlShortener implements UrlShortenerInterface
/**
* @throws NonUniqueSlugException
* @throws InvalidUrlException
*/
public function shorten(ShortUrlCreation $creation): UrlShorteningResult
{
@ -41,7 +39,7 @@ class UrlShortener implements UrlShortenerInterface
return UrlShorteningResult::withoutErrorOnEventDispatching($existingShortUrl);
}
$creation = $this->titleResolutionHelper->processTitleAndValidateUrl($creation);
$creation = $this->titleResolutionHelper->processTitle($creation);
/** @var ShortUrl $newShortUrl */
$newShortUrl = $this->em->wrapInTransaction(function () use ($creation): ShortUrl {

View file

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\ShortUrl\Model\UrlShorteningResult;
@ -13,7 +12,6 @@ interface UrlShortenerInterface
{
/**
* @throws NonUniqueSlugException
* @throws InvalidUrlException
*/
public function shorten(ShortUrlCreation $creation): UrlShorteningResult;
}

View file

@ -1,116 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Util;
use Fig\Http\Message\RequestMethodInterface;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\RequestOptions;
use Psr\Http\Message\ResponseInterface;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Throwable;
use function html_entity_decode;
use function preg_match;
use function str_contains;
use function str_starts_with;
use function strtolower;
use function trim;
use const Shlinkio\Shlink\TITLE_TAG_VALUE;
/** @deprecated */
class UrlValidator implements UrlValidatorInterface, RequestMethodInterface
{
private const MAX_REDIRECTS = 15;
private const CHROME_USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) '
. 'Chrome/112.0.0.0 Safari/537.36';
public function __construct(private ClientInterface $httpClient, private UrlShortenerOptions $options)
{
}
/**
* @deprecated
* @throws InvalidUrlException
*/
public function validateUrl(string $url, bool $doValidate): void
{
if (! $doValidate) {
return;
}
$this->validateUrlAndGetResponse($url);
}
/**
* @deprecated
* @throws InvalidUrlException
*/
public function validateUrlWithTitle(string $url, bool $doValidate): ?string
{
if (! $doValidate && ! $this->options->autoResolveTitles) {
return null;
}
if (! $this->options->autoResolveTitles) {
$this->validateUrlAndGetResponse($url, self::METHOD_HEAD);
return null;
}
$response = $doValidate ? $this->validateUrlAndGetResponse($url) : $this->getResponse($url);
if ($response === null) {
return null;
}
$contentType = strtolower($response->getHeaderLine('Content-Type'));
if (! str_starts_with($contentType, 'text/html')) {
return null;
}
$collectedBody = '';
$body = $response->getBody();
// With streaming enabled, we can walk the body until the </title> tag is found, and then stop
while (! str_contains($collectedBody, '</title>') && ! $body->eof()) {
$collectedBody .= $body->read(1024);
}
preg_match(TITLE_TAG_VALUE, $collectedBody, $matches);
return isset($matches[1]) ? $this->normalizeTitle($matches[1]) : null;
}
/**
* @param self::METHOD_GET|self::METHOD_HEAD $method
* @throws InvalidUrlException
*/
private function validateUrlAndGetResponse(string $url, string $method = self::METHOD_GET): ResponseInterface
{
try {
return $this->httpClient->request($method, $url, [
RequestOptions::ALLOW_REDIRECTS => ['max' => self::MAX_REDIRECTS],
RequestOptions::IDN_CONVERSION => true,
// Making the request with a browser's user agent makes the validation closer to a real user
RequestOptions::HEADERS => ['User-Agent' => self::CHROME_USER_AGENT],
RequestOptions::STREAM => true, // This ensures large files are not fully downloaded if not needed
]);
} catch (GuzzleException $e) {
throw InvalidUrlException::fromUrl($url, $e);
}
}
private function getResponse(string $url): ?ResponseInterface
{
try {
return $this->validateUrlAndGetResponse($url);
} catch (Throwable) {
return null;
}
}
private function normalizeTitle(string $title): string
{
return html_entity_decode(trim($title));
}
}

View file

@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Util;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
/** @deprecated */
interface UrlValidatorInterface
{
/**
* @deprecated
* @throws InvalidUrlException
*/
public function validateUrl(string $url, bool $doValidate): void;
/**
* @deprecated
* @throws InvalidUrlException
*/
public function validateUrlWithTitle(string $url, bool $doValidate): ?string;
}

View file

@ -1,41 +0,0 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Exception;
use Exception;
use Fig\Http\Message\StatusCodeInterface;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Throwable;
use function sprintf;
class InvalidUrlExceptionTest extends TestCase
{
#[Test, DataProvider('providePrevious')]
public function properlyCreatesExceptionFromUrl(?Throwable $prev): void
{
$url = 'http://the_url.com';
$expectedMessage = sprintf('Provided URL %s is invalid. Try with a different one.', $url);
$e = InvalidUrlException::fromUrl($url, $prev);
self::assertEquals($expectedMessage, $e->getMessage());
self::assertEquals($expectedMessage, $e->getDetail());
self::assertEquals('Invalid URL', $e->getTitle());
self::assertEquals('https://shlink.io/api/error/invalid-url', $e->getType());
self::assertEquals(['url' => $url], $e->getAdditionalData());
self::assertEquals(StatusCodeInterface::STATUS_BAD_REQUEST, $e->getCode());
self::assertEquals(StatusCodeInterface::STATUS_BAD_REQUEST, $e->getStatus());
self::assertEquals($prev, $e->getPrevious());
}
public static function providePrevious(): iterable
{
yield 'null previous' => [null];
yield 'instance previous' => [new Exception('Previous error', 10)];
}
}

View file

@ -4,46 +4,144 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\ShortUrl\Helper;
use PHPUnit\Framework\Attributes\DataProvider;
use Exception;
use Fig\Http\Message\RequestMethodInterface;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\RequestOptions;
use Laminas\Diactoros\Response;
use Laminas\Diactoros\Response\JsonResponse;
use Laminas\Diactoros\Stream;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\Builder\InvocationMocker;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlTitleResolutionHelper;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\Util\UrlValidatorInterface;
class ShortUrlTitleResolutionHelperTest extends TestCase
{
private ShortUrlTitleResolutionHelper $helper;
private MockObject & UrlValidatorInterface $urlValidator;
private const LONG_URL = 'http://foobar.com/12345/hello?foo=bar';
private MockObject & ClientInterface $httpClient;
protected function setUp(): void
{
$this->urlValidator = $this->createMock(UrlValidatorInterface::class);
$this->helper = new ShortUrlTitleResolutionHelper($this->urlValidator);
$this->httpClient = $this->createMock(ClientInterface::class);
}
#[Test, DataProvider('provideTitles')]
public function urlIsProperlyShortened(?string $title, int $validateWithTitleCallsNum, int $validateCallsNum): void
#[Test]
public function dataIsReturnedAsIsWhenResolvingTitlesIsDisabled(): void
{
$longUrl = 'http://foobar.com/12345/hello?foo=bar';
$this->urlValidator->expects($this->exactly($validateWithTitleCallsNum))->method('validateUrlWithTitle')->with(
$longUrl,
$this->isFalse(),
);
$this->urlValidator->expects($this->exactly($validateCallsNum))->method('validateUrl')->with(
$longUrl,
$this->isFalse(),
);
$data = ShortUrlCreation::fromRawData(['longUrl' => self::LONG_URL]);
$this->httpClient->expects($this->never())->method('request');
$this->helper->processTitleAndValidateUrl(
ShortUrlCreation::fromRawData(['longUrl' => $longUrl, 'title' => $title]),
$result = $this->helper()->processTitle($data);
self::assertSame($data, $result);
}
#[Test]
public function dataIsReturnedAsIsWhenItAlreadyHasTitle(): void
{
$data = ShortUrlCreation::fromRawData([
'longUrl' => self::LONG_URL,
'title' => 'foo',
]);
$this->httpClient->expects($this->never())->method('request');
$result = $this->helper(autoResolveTitles: true)->processTitle($data);
self::assertSame($data, $result);
}
#[Test]
public function dataIsReturnedAsIsWhenFetchingFails(): void
{
$data = ShortUrlCreation::fromRawData(['longUrl' => self::LONG_URL]);
$this->expectRequestToBeCalled()->willThrowException(new Exception('Error'));
$result = $this->helper(autoResolveTitles: true)->processTitle($data);
self::assertSame($data, $result);
}
#[Test]
public function dataIsReturnedAsIsWhenResponseIsNotHtml(): void
{
$data = ShortUrlCreation::fromRawData(['longUrl' => self::LONG_URL]);
$this->expectRequestToBeCalled()->willReturn(new JsonResponse(['foo' => 'bar']));
$result = $this->helper(autoResolveTitles: true)->processTitle($data);
self::assertSame($data, $result);
}
#[Test]
public function dataIsReturnedAsIsWhenTitleCannotBeResolvedFromResponse(): void
{
$data = ShortUrlCreation::fromRawData(['longUrl' => self::LONG_URL]);
$this->expectRequestToBeCalled()->willReturn($this->respWithoutTitle());
$result = $this->helper(autoResolveTitles: true)->processTitle($data);
self::assertSame($data, $result);
}
#[Test]
public function titleIsUpdatedWhenItCanBeResolvedFromResponse(): void
{
$data = ShortUrlCreation::fromRawData(['longUrl' => self::LONG_URL]);
$this->expectRequestToBeCalled()->willReturn($this->respWithTitle());
$result = $this->helper(autoResolveTitles: true)->processTitle($data);
self::assertNotSame($data, $result);
self::assertEquals('Resolved "title"', $result->title);
}
private function expectRequestToBeCalled(): InvocationMocker
{
return $this->httpClient->expects($this->once())->method('request')->with(
RequestMethodInterface::METHOD_GET,
self::LONG_URL,
[
RequestOptions::TIMEOUT => 3,
RequestOptions::CONNECT_TIMEOUT => 3,
RequestOptions::ALLOW_REDIRECTS => ['max' => ShortUrlTitleResolutionHelper::MAX_REDIRECTS],
RequestOptions::IDN_CONVERSION => true,
RequestOptions::HEADERS => ['User-Agent' => ShortUrlTitleResolutionHelper::CHROME_USER_AGENT],
RequestOptions::STREAM => true,
],
);
}
public static function provideTitles(): iterable
private function respWithoutTitle(): Response
{
yield 'no title' => [null, 1, 0];
yield 'title' => ['link title', 0, 1];
$body = $this->createStreamWithContent('<body>No title</body>');
return new Response($body, 200, ['Content-Type' => 'text/html']);
}
private function respWithTitle(): Response
{
$body = $this->createStreamWithContent('<title data-foo="bar"> Resolved &quot;title&quot; </title>');
return new Response($body, 200, ['Content-Type' => 'TEXT/html; charset=utf-8']);
}
private function createStreamWithContent(string $content): Stream
{
$body = new Stream('php://temp', 'wr');
$body->write($content);
$body->rewind();
return $body;
}
private function helper(bool $autoResolveTitles = false): ShortUrlTitleResolutionHelper
{
return new ShortUrlTitleResolutionHelper(
$this->httpClient,
new UrlShortenerOptions(autoResolveTitles: $autoResolveTitles),
);
}
}

View file

@ -63,7 +63,7 @@ class ShortUrlServiceTest extends TestCase
)->willReturn($shortUrl);
$this->titleResolutionHelper->expects($expectedValidateCalls)
->method('processTitleAndValidateUrl')
->method('processTitle')
->with($shortUrlEdit)
->willReturn($shortUrlEdit);
@ -102,10 +102,6 @@ class ShortUrlServiceTest extends TestCase
'maxVisits' => 10,
'longUrl' => 'https://modifiedLongUrl',
]), ApiKey::create()];
yield 'long URL with validation' => [new InvokedCount(1), ShortUrlEdition::fromRawData([
'longUrl' => 'https://modifiedLongUrl',
'validateUrl' => true,
]), null];
yield 'device redirects' => [new InvokedCount(0), ShortUrlEdition::fromRawData([
'deviceLongUrls' => [
DeviceType::IOS->value => 'https://iosLongUrl',

View file

@ -57,7 +57,7 @@ class UrlShortenerTest extends TestCase
{
$longUrl = 'http://foobar.com/12345/hello?foo=bar';
$meta = ShortUrlCreation::fromRawData(['longUrl' => $longUrl]);
$this->titleResolutionHelper->expects($this->once())->method('processTitleAndValidateUrl')->with(
$this->titleResolutionHelper->expects($this->once())->method('processTitle')->with(
$meta,
)->willReturnArgument(0);
$this->shortCodeHelper->method('ensureShortCodeUniqueness')->willReturn(true);
@ -90,7 +90,7 @@ class UrlShortenerTest extends TestCase
);
$this->shortCodeHelper->expects($this->once())->method('ensureShortCodeUniqueness')->willReturn(false);
$this->titleResolutionHelper->expects($this->once())->method('processTitleAndValidateUrl')->with(
$this->titleResolutionHelper->expects($this->once())->method('processTitle')->with(
$meta,
)->willReturnArgument(0);
@ -105,7 +105,7 @@ class UrlShortenerTest extends TestCase
$repo = $this->createMock(ShortUrlRepository::class);
$repo->expects($this->once())->method('findOneMatching')->willReturn($expected);
$this->em->expects($this->once())->method('getRepository')->with(ShortUrl::class)->willReturn($repo);
$this->titleResolutionHelper->expects($this->never())->method('processTitleAndValidateUrl');
$this->titleResolutionHelper->expects($this->never())->method('processTitle');
$this->shortCodeHelper->method('ensureShortCodeUniqueness')->willReturn(true);
$result = $this->urlShortener->shorten($meta);

View file

@ -1,176 +0,0 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Util;
use Fig\Http\Message\RequestMethodInterface;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\RequestOptions;
use Laminas\Diactoros\Response;
use Laminas\Diactoros\Stream;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Util\UrlValidator;
class UrlValidatorTest extends TestCase
{
private MockObject & ClientInterface $httpClient;
protected function setUp(): void
{
$this->httpClient = $this->createMock(ClientInterface::class);
}
#[Test]
public function exceptionIsThrownWhenUrlIsInvalid(): void
{
$this->httpClient->expects($this->once())->method('request')->willThrowException($this->clientException());
$this->expectException(InvalidUrlException::class);
$this->urlValidator()->validateUrl('http://foobar.com/12345/hello?foo=bar', true);
}
#[Test]
public function expectedUrlIsCalledWhenTryingToVerify(): void
{
$expectedUrl = 'http://foobar.com';
$this->httpClient->expects($this->once())->method('request')->with(
RequestMethodInterface::METHOD_GET,
$expectedUrl,
$this->callback(function (array $options) {
Assert::assertArrayHasKey(RequestOptions::ALLOW_REDIRECTS, $options);
Assert::assertEquals(['max' => 15], $options[RequestOptions::ALLOW_REDIRECTS]);
Assert::assertArrayHasKey(RequestOptions::IDN_CONVERSION, $options);
Assert::assertTrue($options[RequestOptions::IDN_CONVERSION]);
Assert::assertArrayHasKey(RequestOptions::HEADERS, $options);
Assert::assertArrayHasKey('User-Agent', $options[RequestOptions::HEADERS]);
return true;
}),
)->willReturn(new Response());
$this->urlValidator()->validateUrl($expectedUrl, true);
}
#[Test]
public function noCheckIsPerformedWhenUrlValidationIsDisabled(): void
{
$this->httpClient->expects($this->never())->method('request');
$this->urlValidator()->validateUrl('', false);
}
#[Test]
public function validateUrlWithTitleReturnsNullWhenRequestFailsAndValidationIsDisabled(): void
{
$this->httpClient->expects($this->once())->method('request')->willThrowException($this->clientException());
$result = $this->urlValidator(true)->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', false);
self::assertNull($result);
}
#[Test]
public function validateUrlWithTitleReturnsNullWhenAutoResolutionIsDisabled(): void
{
$this->httpClient->expects($this->never())->method('request');
$result = $this->urlValidator()->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', false);
self::assertNull($result);
}
#[Test]
public function validateUrlWithTitleReturnsNullWhenAutoResolutionIsDisabledAndValidationIsEnabled(): void
{
$this->httpClient->expects($this->once())->method('request')->with(
RequestMethodInterface::METHOD_HEAD,
$this->anything(),
$this->anything(),
)->willReturn($this->respWithTitle());
$result = $this->urlValidator()->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', true);
self::assertNull($result);
}
#[Test]
public function validateUrlWithTitleResolvesTitleWhenAutoResolutionIsEnabled(): void
{
$this->httpClient->expects($this->once())->method('request')->with(
RequestMethodInterface::METHOD_GET,
$this->anything(),
$this->anything(),
)->willReturn($this->respWithTitle());
$result = $this->urlValidator(true)->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', true);
self::assertEquals('Resolved "title"', $result);
}
#[Test]
public function validateUrlWithTitleReturnsNullWhenAutoResolutionIsEnabledAndReturnedContentTypeIsInvalid(): void
{
$this->httpClient->expects($this->once())->method('request')->with(
RequestMethodInterface::METHOD_GET,
$this->anything(),
$this->anything(),
)->willReturn(new Response('php://memory', 200, ['Content-Type' => 'application/octet-stream']));
$result = $this->urlValidator(true)->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', true);
self::assertNull($result);
}
#[Test]
public function validateUrlWithTitleReturnsNullWhenAutoResolutionIsEnabledAndBodyDoesNotContainTitle(): void
{
$this->httpClient->expects($this->once())->method('request')->with(
RequestMethodInterface::METHOD_GET,
$this->anything(),
$this->anything(),
)->willReturn(
new Response($this->createStreamWithContent('<body>No title</body>'), 200, ['Content-Type' => 'text/html']),
);
$result = $this->urlValidator(true)->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', true);
self::assertNull($result);
}
private function respWithTitle(): Response
{
$body = $this->createStreamWithContent('<title data-foo="bar"> Resolved &quot;title&quot; </title>');
return new Response($body, 200, ['Content-Type' => 'TEXT/html; charset=utf-8']);
}
private function createStreamWithContent(string $content): Stream
{
$body = new Stream('php://temp', 'wr');
$body->write($content);
$body->rewind();
return $body;
}
private function clientException(): ClientException
{
return new ClientException(
'',
new Request(RequestMethodInterface::METHOD_GET, ''),
new Response(),
);
}
public function urlValidator(bool $autoResolveTitles = false): UrlValidator
{
return new UrlValidator($this->httpClient, new UrlShortenerOptions(autoResolveTitles: $autoResolveTitles));
}
}

View file

@ -224,27 +224,6 @@ class CreateShortUrlTest extends ApiTestCase
yield ['http://téstb.shlink.io']; // Redirects to http://tést.shlink.io
}
#[Test, DataProvider('provideInvalidUrls')]
public function failsToCreateShortUrlWithInvalidLongUrl(string $url, string $version, string $expectedType): void
{
$expectedDetail = sprintf('Provided URL %s is invalid. Try with a different one.', $url);
[$statusCode, $payload] = $this->createShortUrl(['longUrl' => $url, 'validateUrl' => true], version: $version);
self::assertEquals(self::STATUS_BAD_REQUEST, $statusCode);
self::assertEquals(self::STATUS_BAD_REQUEST, $payload['status']);
self::assertEquals($expectedType, $payload['type']);
self::assertEquals($expectedDetail, $payload['detail']);
self::assertEquals('Invalid URL', $payload['title']);
self::assertEquals($url, $payload['url']);
}
public static function provideInvalidUrls(): iterable
{
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'];
}
#[Test, DataProvider('provideInvalidArgumentApiVersions')]
public function failsToCreateShortUrlWithoutLongUrl(array $payload, string $version, string $expectedType): void
{

View file

@ -75,28 +75,16 @@ class EditShortUrlTest extends ApiTestCase
return $matchingShortUrl['meta'] ?? [];
}
#[Test, DataProvider('provideLongUrls')]
public function longUrlCanBeEditedIfItIsValid(string $longUrl, int $expectedStatus, ?string $expectedError): void
public function longUrlCanBeEdited(): void
{
$shortCode = 'abc123';
$url = sprintf('/short-urls/%s', $shortCode);
$resp = $this->callApiWithKey(self::METHOD_PATCH, $url, [RequestOptions::JSON => [
'longUrl' => $longUrl,
'validateUrl' => true,
'longUrl' => 'https://shlink.io',
]]);
self::assertEquals($expectedStatus, $resp->getStatusCode());
if ($expectedError !== null) {
$payload = $this->getJsonResponsePayload($resp);
self::assertEquals($expectedError, $payload['type']);
}
}
public static function provideLongUrls(): iterable
{
yield 'valid URL' => ['https://shlink.io', self::STATUS_OK, null];
yield 'invalid URL' => ['http://foo', self::STATUS_BAD_REQUEST, 'https://shlink.io/api/error/invalid-url'];
self::assertEquals(self::STATUS_OK, $resp->getStatusCode());
}
#[Test, DataProviderExternal(ApiTestDataProviders::class, 'invalidUrlsProvider')]