Merge pull request #1003 from acelaya-forks/feature/title

Feature/title
This commit is contained in:
Alejandro Celaya 2021-02-05 18:54:22 +01:00 committed by GitHub
commit a8b424003c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 529 additions and 114 deletions

View file

@ -189,7 +189,7 @@ jobs:
- name: Checkout code
uses: actions/checkout@v2
- name: Start database server
run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db
run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres
- name: Use PHP
uses: shivammathur/setup-php@v2
with:

View file

@ -7,6 +7,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
## [Unreleased]
### Added
* [#856](https://github.com/shlinkio/shlink/issues/856) Added PHP 8.0 support.
* [#941](https://github.com/shlinkio/shlink/issues/856) Added support to provide a title for every short URL.
The title can also be automatically resolved from the long URL, when no title was explicitly provided, but this option needs to be opted in.
### Changed
* [#977](https://github.com/shlinkio/shlink/issues/977) Migrated from `laminas/laminas-paginator` to `pagerfanta/core` to handle pagination.

View file

@ -1,6 +1,6 @@
#!/usr/bin/env sh
export APP_ENV=test
export DB_DRIVER=mysql
export DB_DRIVER=postgres
export TEST_ENV=api
# Try to stop server just in case it hanged in last execution

View file

@ -50,8 +50,8 @@
"shlinkio/shlink-common": "dev-main#b889f5d as 3.5",
"shlinkio/shlink-config": "^1.0",
"shlinkio/shlink-event-dispatcher": "^2.0",
"shlinkio/shlink-importer": "^2.1",
"shlinkio/shlink-installer": "^5.3",
"shlinkio/shlink-importer": "dev-main#b6fc81f as 2.2",
"shlinkio/shlink-installer": "dev-develop#1ed5ac8 as 5.4",
"shlinkio/shlink-ip-geolocation": "^1.5",
"symfony/console": "^5.1",
"symfony/filesystem": "^5.1",
@ -64,7 +64,7 @@
"devster/ubench": "^2.1",
"dms/phpunit-arraysubset-asserts": "^0.2.1",
"eaglewu/swoole-ide-helper": "dev-master",
"infection/infection": "^0.20.2",
"infection/infection": "^0.21.0",
"phpspec/prophecy-phpunit": "^2.0",
"phpstan/phpstan": "^0.12.64",
"phpunit/php-code-coverage": "^9.2",

View file

@ -40,6 +40,7 @@ return [
Option\UrlShortener\IpAnonymizationConfigOption::class,
Option\UrlShortener\RedirectStatusCodeConfigOption::class,
Option\UrlShortener\RedirectCacheLifeTimeConfigOption::class,
Option\UrlShortener\AutoResolveTitlesConfigOption::class,
],
'installation_commands' => [

View file

@ -19,6 +19,7 @@ return [
'default_short_codes_length' => DEFAULT_SHORT_CODES_LENGTH,
'redirect_status_code' => DEFAULT_REDIRECT_STATUS_CODE,
'redirect_cache_lifetime' => DEFAULT_REDIRECT_CACHE_LIFETIME,
'auto_resolve_titles' => false,
],
];

View file

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration;
final class Version20210202181026 extends AbstractMigration
{
private const TITLE = 'title';
public function up(Schema $schema): void
{
$shortUrls = $schema->getTable('short_urls');
$this->skipIf($shortUrls->hasColumn(self::TITLE));
$shortUrls->addColumn(self::TITLE, Types::STRING, [
'notnull' => false,
'length' => 512,
]);
$shortUrls->addColumn('title_was_auto_resolved', Types::BOOLEAN, [
'default' => false,
]);
}
public function down(Schema $schema): void
{
$shortUrls = $schema->getTable('short_urls');
$this->skipIf(! $shortUrls->hasColumn(self::TITLE));
$shortUrls->dropColumn(self::TITLE);
$shortUrls->dropColumn('title_was_auto_resolved');
}
/**
* @fixme Workaround for https://github.com/doctrine/migrations/issues/1104
*/
public function isTransactional(): bool
{
return false;
}
}

View file

@ -125,6 +125,7 @@ return [
'default_short_codes_length' => $helper->getDefaultShortCodesLength(),
'redirect_status_code' => (int) env('REDIRECT_STATUS_CODE', DEFAULT_REDIRECT_STATUS_CODE),
'redirect_cache_lifetime' => (int) env('REDIRECT_CACHE_LIFETIME', DEFAULT_REDIRECT_CACHE_LIFETIME),
'auto_resolve_titles' => (bool) env('AUTO_RESOLVE_TITLES', false),
],
'not_found_redirects' => $helper->getNotFoundRedirectsConfig(),

View file

@ -34,7 +34,13 @@
},
"domain": {
"type": "string",
"nullable": true,
"description": "The domain in which the short URL was created. Null if it belongs to default domain."
},
"title": {
"type": "string",
"nullable": true,
"description": "A descriptive title of the short URL."
}
}
}

View file

@ -64,7 +64,9 @@
"dateCreated-ASC",
"dateCreated-DESC",
"visits-ASC",
"visits-DESC"
"visits-DESC",
"title-ASC",
"title-DESC"
]
}
},
@ -137,7 +139,8 @@
"validUntil": null,
"maxVisits": 100
},
"domain": null
"domain": null,
"title": "Welcome to Steam"
},
{
"shortCode": "12Kb3",
@ -153,7 +156,8 @@
"validUntil": null,
"maxVisits": null
},
"domain": null
"domain": null,
"title": null
},
{
"shortCode": "123bA",
@ -167,7 +171,8 @@
"validUntil": null,
"maxVisits": null
},
"domain": "example.com"
"domain": "example.com",
"title": null
}
],
"pagination": {
@ -264,6 +269,10 @@
"validateUrl": {
"description": "Tells if the long URL should or should not be validated as a reachable URL. If not provided, it will fall back to app-level config",
"type": "boolean"
},
"title": {
"type": "string",
"description": "A descriptive title of the short URL."
}
}
}

View file

@ -73,7 +73,8 @@
"validUntil": null,
"maxVisits": 100
},
"domain": null
"domain": null,
"title": null
},
"text/plain": "https://doma.in/abc123"
}

View file

@ -53,7 +53,8 @@
"validUntil": null,
"maxVisits": 100
},
"domain": null
"domain": null,
"title": null
}
}
},
@ -118,15 +119,18 @@
},
"validSince": {
"description": "The date (in ISO-8601 format) from which this short code will be valid",
"type": "string"
"type": "string",
"nullable": true
},
"validUntil": {
"description": "The date (in ISO-8601 format) until which this short code will be valid",
"type": "string"
"type": "string",
"nullable": true
},
"maxVisits": {
"description": "The maximum number of allowed visits for this short code",
"type": "number"
"type": "number",
"nullable": true
},
"validateUrl": {
"description": "Tells if the long URL (if provided) should or should not be validated as a reachable URL. If not provided, it will fall back to app-level config",
@ -138,6 +142,11 @@
"type": "string"
},
"description": "The list of tags to set to the short URL."
},
"title": {
"type": "string",
"description": "A descriptive title of the short URL.",
"nullable": true
}
}
}
@ -174,7 +183,8 @@
"validUntil": null,
"maxVisits": 100
},
"domain": null
"domain": null,
"title": "Shlink - The URL shortener"
}
}
},

View file

@ -19,11 +19,9 @@ use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function array_flip;
use function array_intersect_key;
use function array_values;
use function count;
use function array_pad;
use function explode;
use function Functional\map;
use function implode;
use function sprintf;
@ -32,12 +30,16 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
use PagerfantaUtilsTrait;
public const NAME = 'short-url:list';
private const COLUMNS_WHITELIST = [
private const COLUMNS_TO_SHOW = [
'shortCode',
'title',
'shortUrl',
'longUrl',
'dateCreated',
'visitsCount',
];
private const COLUMNS_TO_SHOW_WITH_TAGS = [
...self::COLUMNS_TO_SHOW,
'tags',
];
@ -79,7 +81,8 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
'order-by',
'o',
InputOption::VALUE_REQUIRED,
'The field from which we want to order by. Pass ASC or DESC separated by a comma.',
'The field from which you want to order by. '
. 'Define ordering dir by passing ASC or DESC after "," or "-".',
)
->addOptionWithDeprecatedFallback(
'show-tags',
@ -153,21 +156,20 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
{
$result = $this->shortUrlService->listShortUrls($params);
$headers = ['Short code', 'Short URL', 'Long URL', 'Date created', 'Visits count'];
$headers = ['Short code', 'Title', 'Short URL', 'Long URL', 'Date created', 'Visits count'];
if ($showTags) {
$headers[] = 'Tags';
}
$rows = [];
foreach ($result as $row) {
$columnsToShow = $showTags ? self::COLUMNS_TO_SHOW_WITH_TAGS : self::COLUMNS_TO_SHOW;
$shortUrl = $this->transformer->transform($row);
if ($showTags) {
$shortUrl['tags'] = implode(', ', $shortUrl['tags']);
} else {
unset($shortUrl['tags']);
}
$rows[] = array_values(array_intersect_key($shortUrl, array_flip(self::COLUMNS_WHITELIST)));
$rows[] = map($columnsToShow, fn (string $prop) => $shortUrl[$prop]);
}
ShlinkTable::fromOutput($output)->render($headers, $rows, $all ? null : $this->formatCurrentPageMessage(
@ -178,17 +180,14 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
return $result;
}
/**
* @return array|string|null
*/
private function processOrderBy(InputInterface $input)
private function processOrderBy(InputInterface $input): ?string
{
$orderBy = $this->getOptionWithDeprecatedFallback($input, 'order-by');
if (empty($orderBy)) {
return null;
}
$orderBy = explode(',', $orderBy);
return count($orderBy) === 1 ? $orderBy[0] : [$orderBy[0] => $orderBy[1]];
[$field, $dir] = array_pad(explode(',', $orderBy), 2, null);
return $dir === null ? $field : sprintf('%s-%s', $field, $dir);
}
}

View file

@ -44,6 +44,7 @@ return [
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => ConfigAbstractFactory::class,
ShortUrl\Helper\ShortUrlStringifier::class => ConfigAbstractFactory::class,
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => ConfigAbstractFactory::class,
ShortUrl\Transformer\ShortUrlDataTransformer::class => ConfigAbstractFactory::class,
Mercure\MercureUpdatesGenerator::class => ConfigAbstractFactory::class,
@ -69,7 +70,7 @@ return [
Options\UrlShortenerOptions::class => ['config.url_shortener'],
Service\UrlShortener::class => [
Util\UrlValidator::class,
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class,
'em',
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class,
Service\ShortUrl\ShortCodeHelper::class,
@ -82,7 +83,7 @@ return [
Service\ShortUrlService::class => [
'em',
Service\ShortUrl\ShortUrlResolver::class,
Util\UrlValidator::class,
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class,
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class,
],
Visit\VisitLocator::class => ['em'],
@ -122,6 +123,7 @@ return [
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => ['em'],
ShortUrl\Helper\ShortUrlStringifier::class => ['config.url_shortener.domain', 'config.router.base_path'],
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => [Util\UrlValidator::class],
ShortUrl\Transformer\ShortUrlDataTransformer::class => [ShortUrl\Helper\ShortUrlStringifier::class],
Mercure\MercureUpdatesGenerator::class => [ShortUrl\Transformer\ShortUrlDataTransformer::class],

View file

@ -84,4 +84,15 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
->build();
$builder->addUniqueConstraint(['short_code', 'domain_id'], 'unique_short_code_plus_domain');
$builder->createField('title', Types::STRING)
->columnName('title')
->length(512)
->nullable()
->build();
$builder->createField('titleWasAutoResolved', Types::BOOLEAN)
->columnName('title_was_auto_resolved')
->option('default', false)
->build();
};

View file

@ -26,6 +26,7 @@ const DEFAULT_REDIRECT_STATUS_CODE = StatusCodeInterface::STATUS_FOUND;
const DEFAULT_REDIRECT_CACHE_LIFETIME = 30;
const LOCAL_LOCK_FACTORY = 'Shlinkio\Shlink\LocalLockFactory';
const CUSTOM_SLUGS_REGEXP = '/[^\pL\pN._~]/u'; // Any unicode letter or number, plus ".", "_" and "~" chars
const TITLE_TAG_VALUE = '/<title[^>]*>(.*?)<\/title>/i'; // Matches the value inside an html title tag
function generateRandomShortCode(int $length): string
{

View file

@ -38,6 +38,8 @@ class ShortUrl extends AbstractEntity
private ?string $importSource = null;
private ?string $importOriginalShortCode = null;
private ?ApiKey $authorApiKey = null;
private ?string $title = null;
private bool $titleWasAutoResolved = false;
private function __construct()
{
@ -72,6 +74,8 @@ class ShortUrl extends AbstractEntity
$instance->shortCode = $meta->getCustomSlug() ?? generateRandomShortCode($instance->shortCodeLength);
$instance->domain = $relationResolver->resolveDomain($meta->getDomain());
$instance->authorApiKey = $meta->getApiKey();
$instance->title = $meta->getTitle();
$instance->titleWasAutoResolved = $meta->titleWasAutoResolved();
return $instance;
}
@ -85,6 +89,7 @@ class ShortUrl extends AbstractEntity
ShortUrlInputFilter::LONG_URL => $url->longUrl(),
ShortUrlInputFilter::DOMAIN => $url->domain(),
ShortUrlInputFilter::TAGS => $url->tags(),
ShortUrlInputFilter::TITLE => $url->title(),
ShortUrlInputFilter::VALIDATE_URL => false,
];
if ($importShortCode) {
@ -157,26 +162,39 @@ class ShortUrl extends AbstractEntity
return $this->maxVisits;
}
public function getTitle(): ?string
{
return $this->title;
}
public function update(
ShortUrlEdit $shortUrlEdit,
?ShortUrlRelationResolverInterface $relationResolver = null
): void {
if ($shortUrlEdit->hasValidSince()) {
if ($shortUrlEdit->validSinceWasProvided()) {
$this->validSince = $shortUrlEdit->validSince();
}
if ($shortUrlEdit->hasValidUntil()) {
if ($shortUrlEdit->validUntilWasProvided()) {
$this->validUntil = $shortUrlEdit->validUntil();
}
if ($shortUrlEdit->hasMaxVisits()) {
if ($shortUrlEdit->maxVisitsWasProvided()) {
$this->maxVisits = $shortUrlEdit->maxVisits();
}
if ($shortUrlEdit->hasLongUrl()) {
$this->longUrl = $shortUrlEdit->longUrl();
if ($shortUrlEdit->longUrlWasProvided()) {
$this->longUrl = $shortUrlEdit->longUrl() ?? $this->longUrl;
}
if ($shortUrlEdit->hasTags()) {
if ($shortUrlEdit->tagsWereProvided()) {
$relationResolver = $relationResolver ?? new SimpleShortUrlRelationResolver();
$this->tags = $relationResolver->resolveTags($shortUrlEdit->tags());
}
if (
$this->title === null
|| $shortUrlEdit->titleWasProvided()
|| ($this->titleWasAutoResolved && $shortUrlEdit->titleWasAutoResolved())
) {
$this->title = $shortUrlEdit->title();
$this->titleWasAutoResolved = $shortUrlEdit->titleWasAutoResolved();
}
}
/**

View file

@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\Model;
use Cake\Chronos\Chronos;
use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\ShortUrl\Helper\TitleResolutionModelInterface;
use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter;
use function array_key_exists;
@ -13,7 +14,7 @@ use function Shlinkio\Shlink\Core\getOptionalBoolFromInputFilter;
use function Shlinkio\Shlink\Core\getOptionalIntFromInputFilter;
use function Shlinkio\Shlink\Core\parseDateField;
final class ShortUrlEdit
final class ShortUrlEdit implements TitleResolutionModelInterface
{
private bool $longUrlPropWasProvided = false;
private ?string $longUrl = null;
@ -25,6 +26,9 @@ final class ShortUrlEdit
private ?int $maxVisits = null;
private bool $tagsPropWasProvided = false;
private array $tags = [];
private bool $titlePropWasProvided = false;
private ?string $title = null;
private bool $titleWasAutoResolved = false;
private ?bool $validateUrl = null;
private function __construct()
@ -56,6 +60,7 @@ final class ShortUrlEdit
$this->validUntilPropWasProvided = array_key_exists(ShortUrlInputFilter::VALID_UNTIL, $data);
$this->maxVisitsPropWasProvided = array_key_exists(ShortUrlInputFilter::MAX_VISITS, $data);
$this->tagsPropWasProvided = array_key_exists(ShortUrlInputFilter::TAGS, $data);
$this->titlePropWasProvided = array_key_exists(ShortUrlInputFilter::TITLE, $data);
$this->longUrl = $inputFilter->getValue(ShortUrlInputFilter::LONG_URL);
$this->validSince = parseDateField($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE));
@ -63,6 +68,7 @@ final class ShortUrlEdit
$this->maxVisits = getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS);
$this->validateUrl = getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::VALIDATE_URL);
$this->tags = $inputFilter->getValue(ShortUrlInputFilter::TAGS);
$this->title = $inputFilter->getValue(ShortUrlInputFilter::TITLE);
}
public function longUrl(): ?string
@ -70,7 +76,12 @@ final class ShortUrlEdit
return $this->longUrl;
}
public function hasLongUrl(): bool
public function getLongUrl(): string
{
return $this->longUrl() ?? '';
}
public function longUrlWasProvided(): bool
{
return $this->longUrlPropWasProvided && $this->longUrl !== null;
}
@ -80,7 +91,7 @@ final class ShortUrlEdit
return $this->validSince;
}
public function hasValidSince(): bool
public function validSinceWasProvided(): bool
{
return $this->validSincePropWasProvided;
}
@ -90,7 +101,7 @@ final class ShortUrlEdit
return $this->validUntil;
}
public function hasValidUntil(): bool
public function validUntilWasProvided(): bool
{
return $this->validUntilPropWasProvided;
}
@ -100,7 +111,7 @@ final class ShortUrlEdit
return $this->maxVisits;
}
public function hasMaxVisits(): bool
public function maxVisitsWasProvided(): bool
{
return $this->maxVisitsPropWasProvided;
}
@ -113,11 +124,40 @@ final class ShortUrlEdit
return $this->tags;
}
public function hasTags(): bool
public function tagsWereProvided(): bool
{
return $this->tagsPropWasProvided;
}
public function title(): ?string
{
return $this->title;
}
public function titleWasProvided(): bool
{
return $this->titlePropWasProvided;
}
public function hasTitle(): bool
{
return $this->titleWasProvided();
}
public function titleWasAutoResolved(): bool
{
return $this->titleWasAutoResolved;
}
public function withResolvedTitle(string $title): self
{
$copy = clone $this;
$copy->title = $title;
$copy->titleWasAutoResolved = true;
return $copy;
}
public function doValidateUrl(): ?bool
{
return $this->validateUrl;

View file

@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\Model;
use Cake\Chronos\Chronos;
use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\ShortUrl\Helper\TitleResolutionModelInterface;
use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
@ -15,7 +16,7 @@ use function Shlinkio\Shlink\Core\parseDateField;
use const Shlinkio\Shlink\Core\DEFAULT_SHORT_CODES_LENGTH;
final class ShortUrlMeta
final class ShortUrlMeta implements TitleResolutionModelInterface
{
private string $longUrl;
private ?Chronos $validSince = null;
@ -28,6 +29,8 @@ final class ShortUrlMeta
private ?bool $validateUrl = null;
private ?ApiKey $apiKey = null;
private array $tags = [];
private ?string $title = null;
private bool $titleWasAutoResolved = false;
private function __construct()
{
@ -76,6 +79,7 @@ final class ShortUrlMeta
) ?? DEFAULT_SHORT_CODES_LENGTH;
$this->apiKey = $inputFilter->getValue(ShortUrlInputFilter::API_KEY);
$this->tags = $inputFilter->getValue(ShortUrlInputFilter::TAGS);
$this->title = $inputFilter->getValue(ShortUrlInputFilter::TITLE);
}
public function getLongUrl(): string
@ -160,4 +164,28 @@ final class ShortUrlMeta
{
return $this->tags;
}
public function getTitle(): ?string
{
return $this->title;
}
public function hasTitle(): bool
{
return $this->title !== null;
}
public function titleWasAutoResolved(): bool
{
return $this->titleWasAutoResolved;
}
public function withResolvedTitle(string $title): self
{
$copy = clone $this;
$copy->title = $title;
$copy->titleWasAutoResolved = true;
return $copy;
}
}

View file

@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\Model;
use Shlinkio\Shlink\Core\Exception\ValidationException;
use function array_pad;
use function explode;
use function is_array;
use function is_string;
@ -50,9 +51,9 @@ final class ShortUrlsOrdering
/** @var string|array $orderBy */
if (! $isArray) {
$parts = explode('-', $orderBy);
$this->orderField = $parts[0];
$this->orderDirection = $parts[1] ?? self::DEFAULT_ORDER_DIRECTION;
[$field, $dir] = array_pad(explode('-', $orderBy), 2, null);
$this->orderField = $field;
$this->orderDirection = $dir ?? self::DEFAULT_ORDER_DIRECTION;
} else {
$this->orderField = key($orderBy);
$this->orderDirection = $orderBy[$this->orderField];

View file

@ -18,6 +18,7 @@ class UrlShortenerOptions extends AbstractOptions
private bool $validateUrl = true;
private int $redirectStatusCode = DEFAULT_REDIRECT_STATUS_CODE;
private int $redirectCacheLifetime = DEFAULT_REDIRECT_CACHE_LIFETIME;
private bool $autoResolveTitles = false;
public function isUrlValidationEnabled(): bool
{
@ -55,4 +56,15 @@ class UrlShortenerOptions extends AbstractOptions
? $redirectCacheLifetime
: DEFAULT_REDIRECT_CACHE_LIFETIME;
}
public function autoResolveTitles(): bool
{
return $this->autoResolveTitles;
}
protected function setAutoResolveTitles(bool $autoResolveTitles): self
{
$this->autoResolveTitles = $autoResolveTitles;
return $this;
}
}

View file

@ -55,6 +55,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
$fieldName = $orderBy->orderField();
$order = $orderBy->orderDirection();
// visitsCount and visitCount are deprecated. Only visits should work
if (contains(['visits', 'visitsCount', 'visitCount'], $fieldName)) {
$qb->addSelect('COUNT(DISTINCT v) AS totalVisits')
->leftJoin('s.visits', 'v')
@ -66,10 +67,11 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
// Map public field names to column names
$fieldNameMap = [
'originalUrl' => 'longUrl',
'originalUrl' => 'longUrl', // Deprecated
'longUrl' => 'longUrl',
'shortCode' => 'shortCode',
'dateCreated' => 'dateCreated',
'title' => 'title',
];
if (array_key_exists($fieldName, $fieldNameMap)) {
$qb->orderBy('s.' . $fieldNameMap[$fieldName], $order);
@ -120,6 +122,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
->andWhere($qb->expr()->orX(
$qb->expr()->like('s.longUrl', ':searchPattern'),
$qb->expr()->like('s.shortCode', ':searchPattern'),
$qb->expr()->like('s.title', ':searchPattern'),
$qb->expr()->like('t.name', ':searchPattern'),
$qb->expr()->like('d.authority', ':searchPattern'),
))

View file

@ -15,26 +15,26 @@ use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\Paginator\Adapter\ShortUrlRepositoryAdapter;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlTitleResolutionHelperInterface;
use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface;
use Shlinkio\Shlink\Core\Util\UrlValidatorInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class ShortUrlService implements ShortUrlServiceInterface
{
private ORM\EntityManagerInterface $em;
private ShortUrlResolverInterface $urlResolver;
private UrlValidatorInterface $urlValidator;
private ShortUrlTitleResolutionHelperInterface $titleResolutionHelper;
private ShortUrlRelationResolverInterface $relationResolver;
public function __construct(
ORM\EntityManagerInterface $em,
ShortUrlResolverInterface $urlResolver,
UrlValidatorInterface $urlValidator,
ShortUrlTitleResolutionHelperInterface $titleResolutionHelper,
ShortUrlRelationResolverInterface $relationResolver
) {
$this->em = $em;
$this->urlResolver = $urlResolver;
$this->urlValidator = $urlValidator;
$this->titleResolutionHelper = $titleResolutionHelper;
$this->relationResolver = $relationResolver;
}
@ -61,8 +61,9 @@ class ShortUrlService implements ShortUrlServiceInterface
ShortUrlEdit $shortUrlEdit,
?ApiKey $apiKey = null
): ShortUrl {
if ($shortUrlEdit->hasLongUrl()) {
$this->urlValidator->validateUrl($shortUrlEdit->longUrl(), $shortUrlEdit->doValidateUrl());
if ($shortUrlEdit->longUrlWasProvided()) {
/** @var ShortUrlEdit $shortUrlEdit */
$shortUrlEdit = $this->titleResolutionHelper->processTitleAndValidateUrl($shortUrlEdit);
}
$shortUrl = $this->urlResolver->resolveShortUrl($identifier, $apiKey);

View file

@ -11,24 +11,23 @@ use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortCodeHelperInterface;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlTitleResolutionHelperInterface;
use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface;
use Shlinkio\Shlink\Core\Util\UrlValidatorInterface;
use Throwable;
class UrlShortener implements UrlShortenerInterface
{
private EntityManagerInterface $em;
private UrlValidatorInterface $urlValidator;
private ShortUrlTitleResolutionHelperInterface $titleResolutionHelper;
private ShortUrlRelationResolverInterface $relationResolver;
private ShortCodeHelperInterface $shortCodeHelper;
public function __construct(
UrlValidatorInterface $urlValidator,
ShortUrlTitleResolutionHelperInterface $titleResolutionHelper,
EntityManagerInterface $em,
ShortUrlRelationResolverInterface $relationResolver,
ShortCodeHelperInterface $shortCodeHelper
) {
$this->urlValidator = $urlValidator;
$this->titleResolutionHelper = $titleResolutionHelper;
$this->em = $em;
$this->relationResolver = $relationResolver;
$this->shortCodeHelper = $shortCodeHelper;
@ -37,7 +36,6 @@ class UrlShortener implements UrlShortenerInterface
/**
* @throws NonUniqueSlugException
* @throws InvalidUrlException
* @throws Throwable
*/
public function shorten(ShortUrlMeta $meta): ShortUrl
{
@ -47,7 +45,8 @@ class UrlShortener implements UrlShortenerInterface
return $existingShortUrl;
}
$this->urlValidator->validateUrl($meta->getLongUrl(), $meta->doValidateUrl());
/** @var ShortUrlMeta $meta */
$meta = $this->titleResolutionHelper->processTitleAndValidateUrl($meta);
return $this->em->transactional(function () use ($meta) {
$shortUrl = ShortUrl::fromMeta($meta, $this->relationResolver);

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Helper;
use Shlinkio\Shlink\Core\Util\UrlValidatorInterface;
class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionHelperInterface
{
private UrlValidatorInterface $urlValidator;
public function __construct(UrlValidatorInterface $urlValidator)
{
$this->urlValidator = $urlValidator;
}
public function processTitleAndValidateUrl(TitleResolutionModelInterface $data): TitleResolutionModelInterface
{
if ($data->hasTitle()) {
$this->urlValidator->validateUrl($data->getLongUrl(), $data->doValidateUrl());
return $data;
}
$title = $this->urlValidator->validateUrlWithTitle($data->getLongUrl(), $data->doValidateUrl());
return $title === null ? $data : $data->withResolvedTitle($title);
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Helper;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
interface ShortUrlTitleResolutionHelperInterface
{
/**
* @throws InvalidUrlException
*/
public function processTitleAndValidateUrl(TitleResolutionModelInterface $data): TitleResolutionModelInterface;
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Helper;
interface TitleResolutionModelInterface
{
public function hasTitle(): bool;
public function getLongUrl(): string;
public function doValidateUrl(): ?bool;
public function withResolvedTitle(string $title): self;
}

View file

@ -34,6 +34,7 @@ class ShortUrlDataTransformer implements DataTransformerInterface
'tags' => invoke($shortUrl->getTags(), '__toString'),
'meta' => $this->buildMeta($shortUrl),
'domain' => $shortUrl->getDomain(),
'title' => $shortUrl->getTitle(),
];
}

View file

@ -8,9 +8,15 @@ 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 function preg_match;
use function trim;
use const Shlinkio\Shlink\Core\TITLE_TAG_VALUE;
class UrlValidator implements UrlValidatorInterface, RequestMethodInterface
{
private const MAX_REDIRECTS = 15;
@ -35,13 +41,39 @@ class UrlValidator implements UrlValidatorInterface, RequestMethodInterface
return;
}
$this->validateUrlAndGetResponse($url, true);
}
public function validateUrlWithTitle(string $url, ?bool $doValidate): ?string
{
$doValidate = $doValidate ?? $this->options->isUrlValidationEnabled();
if (! $doValidate && ! $this->options->autoResolveTitles()) {
return null;
}
$response = $this->validateUrlAndGetResponse($url, $doValidate);
if ($response === null) {
return null;
}
$body = $response->getBody()->__toString();
preg_match(TITLE_TAG_VALUE, $body, $matches);
return isset($matches[1]) ? trim($matches[1]) : null;
}
private function validateUrlAndGetResponse(string $url, bool $throwOnError): ?ResponseInterface
{
try {
$this->httpClient->request(self::METHOD_GET, $url, [
return $this->httpClient->request(self::METHOD_GET, $url, [
RequestOptions::ALLOW_REDIRECTS => ['max' => self::MAX_REDIRECTS],
RequestOptions::IDN_CONVERSION => true,
]);
} catch (GuzzleException $e) {
throw InvalidUrlException::fromUrl($url, $e);
if ($throwOnError) {
throw InvalidUrlException::fromUrl($url, $e);
}
return null;
}
}
}

View file

@ -12,4 +12,9 @@ interface UrlValidatorInterface
* @throws InvalidUrlException
*/
public function validateUrl(string $url, ?bool $doValidate): void;
/**
* @throws InvalidUrlException
*/
public function validateUrlWithTitle(string $url, ?bool $doValidate): ?string;
}

View file

@ -31,6 +31,7 @@ class ShortUrlInputFilter extends InputFilter
public const VALIDATE_URL = 'validateUrl';
public const API_KEY = 'apiKey';
public const TAGS = 'tags';
public const TITLE = 'title';
private function __construct(array $data, bool $requireLongUrl)
{
@ -87,6 +88,8 @@ class ShortUrlInputFilter extends InputFilter
$this->add($this->createBooleanInput(self::FIND_IF_EXISTS, false));
// This cannot be defined as a boolean input because it can actually have 3 values, true, false and null.
// Defining it as boolean will make null fall back to false, which is not the desired behavior.
$this->add($this->createInput(self::VALIDATE_URL, false));
$domain = $this->createInput(self::DOMAIN, false);
@ -100,5 +103,7 @@ class ShortUrlInputFilter extends InputFilter
$this->add($apiKeyInput);
$this->add($this->createTagsInput(self::TAGS, false));
$this->add($this->createInput(self::TITLE, false));
}
}

View file

@ -418,7 +418,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
public function importedShortUrlsAreSearchedAsExpected(): void
{
$buildImported = static fn (string $shortCode, ?String $domain = null) =>
new ImportedShlinkUrl('', 'foo', [], Chronos::now(), $domain, $shortCode);
new ImportedShlinkUrl('', 'foo', [], Chronos::now(), $domain, $shortCode, null);
$shortUrlWithoutDomain = ShortUrl::fromImport($buildImported('my-cool-slug'), true);
$this->getEntityManager()->persist($shortUrlWithoutDomain);

View file

@ -64,7 +64,7 @@ class ShortUrlTest extends TestCase
{
yield 'no custom slug' => [ShortUrl::createEmpty()];
yield 'imported with custom slug' => [
ShortUrl::fromImport(new ImportedShlinkUrl('', '', [], Chronos::now(), null, 'custom-slug'), true),
ShortUrl::fromImport(new ImportedShlinkUrl('', '', [], Chronos::now(), null, 'custom-slug', null), true),
];
}

View file

@ -58,9 +58,9 @@ class ImportedLinksProcessorTest extends TestCase
public function newUrlsWithNoErrorsAreAllPersisted(): void
{
$urls = [
new ImportedShlinkUrl('', 'foo', [], Chronos::now(), null, 'foo'),
new ImportedShlinkUrl('', 'bar', [], Chronos::now(), null, 'bar'),
new ImportedShlinkUrl('', 'baz', [], Chronos::now(), null, 'baz'),
new ImportedShlinkUrl('', 'foo', [], Chronos::now(), null, 'foo', null),
new ImportedShlinkUrl('', 'bar', [], Chronos::now(), null, 'bar', 'foo'),
new ImportedShlinkUrl('', 'baz', [], Chronos::now(), null, 'baz', null),
];
$expectedCalls = count($urls);
@ -80,11 +80,11 @@ class ImportedLinksProcessorTest extends TestCase
public function alreadyImportedUrlsAreSkipped(): void
{
$urls = [
new ImportedShlinkUrl('', 'foo', [], Chronos::now(), null, 'foo'),
new ImportedShlinkUrl('', 'bar', [], Chronos::now(), null, 'bar'),
new ImportedShlinkUrl('', 'baz', [], Chronos::now(), null, 'baz'),
new ImportedShlinkUrl('', 'baz2', [], Chronos::now(), null, 'baz2'),
new ImportedShlinkUrl('', 'baz3', [], Chronos::now(), null, 'baz3'),
new ImportedShlinkUrl('', 'foo', [], Chronos::now(), null, 'foo', null),
new ImportedShlinkUrl('', 'bar', [], Chronos::now(), null, 'bar', null),
new ImportedShlinkUrl('', 'baz', [], Chronos::now(), null, 'baz', null),
new ImportedShlinkUrl('', 'baz2', [], Chronos::now(), null, 'baz2', null),
new ImportedShlinkUrl('', 'baz3', [], Chronos::now(), null, 'baz3', null),
];
$contains = fn (string $needle) => fn (string $text) => str_contains($text, $needle);
@ -110,11 +110,11 @@ class ImportedLinksProcessorTest extends TestCase
public function nonUniqueShortCodesAreAskedToUser(): void
{
$urls = [
new ImportedShlinkUrl('', 'foo', [], Chronos::now(), null, 'foo'),
new ImportedShlinkUrl('', 'bar', [], Chronos::now(), null, 'bar'),
new ImportedShlinkUrl('', 'baz', [], Chronos::now(), null, 'baz'),
new ImportedShlinkUrl('', 'baz2', [], Chronos::now(), null, 'baz2'),
new ImportedShlinkUrl('', 'baz3', [], Chronos::now(), null, 'baz3'),
new ImportedShlinkUrl('', 'foo', [], Chronos::now(), null, 'foo', null),
new ImportedShlinkUrl('', 'bar', [], Chronos::now(), null, 'bar', null),
new ImportedShlinkUrl('', 'baz', [], Chronos::now(), null, 'baz', 'foo'),
new ImportedShlinkUrl('', 'baz2', [], Chronos::now(), null, 'baz2', null),
new ImportedShlinkUrl('', 'baz3', [], Chronos::now(), null, 'baz3', 'bar'),
];
$contains = fn (string $needle) => fn (string $text) => str_contains($text, $needle);

View file

@ -28,9 +28,13 @@ class MercureUpdatesGeneratorTest extends TestCase
* @test
* @dataProvider provideMethod
*/
public function visitIsProperlySerializedIntoUpdate(string $method, string $expectedTopic): void
public function visitIsProperlySerializedIntoUpdate(string $method, string $expectedTopic, ?string $title): void
{
$shortUrl = ShortUrl::fromMeta(ShortUrlMeta::fromRawData(['customSlug' => 'foo', 'longUrl' => '']));
$shortUrl = ShortUrl::fromMeta(ShortUrlMeta::fromRawData([
'customSlug' => 'foo',
'longUrl' => '',
'title' => $title,
]));
$visit = new Visit($shortUrl, Visitor::emptyInstance());
$update = $this->generator->{$method}($visit);
@ -50,6 +54,7 @@ class MercureUpdatesGeneratorTest extends TestCase
'maxVisits' => null,
],
'domain' => null,
'title' => $title,
],
'visit' => [
'referer' => '',
@ -62,7 +67,7 @@ class MercureUpdatesGeneratorTest extends TestCase
public function provideMethod(): iterable
{
yield 'newVisitUpdate' => ['newVisitUpdate', 'https://shlink.io/new-visit'];
yield 'newShortUrlVisitUpdate' => ['newShortUrlVisitUpdate', 'https://shlink.io/new-visit/foo'];
yield 'newVisitUpdate' => ['newVisitUpdate', 'https://shlink.io/new-visit', 'the cool title'];
yield 'newShortUrlVisitUpdate' => ['newShortUrlVisitUpdate', 'https://shlink.io/new-visit/foo', null];
}
}

View file

@ -17,8 +17,8 @@ use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Core\Service\ShortUrlService;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlTitleResolutionHelperInterface;
use Shlinkio\Shlink\Core\ShortUrl\Resolver\SimpleShortUrlRelationResolver;
use Shlinkio\Shlink\Core\Util\UrlValidatorInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use ShlinkioTest\Shlink\Core\Util\ApiKeyHelpersTrait;
@ -32,7 +32,7 @@ class ShortUrlServiceTest extends TestCase
private ShortUrlService $service;
private ObjectProphecy $em;
private ObjectProphecy $urlResolver;
private ObjectProphecy $urlValidator;
private ObjectProphecy $titleResolutionHelper;
public function setUp(): void
{
@ -41,12 +41,12 @@ class ShortUrlServiceTest extends TestCase
$this->em->flush()->willReturn(null);
$this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class);
$this->urlValidator = $this->prophesize(UrlValidatorInterface::class);
$this->titleResolutionHelper = $this->prophesize(ShortUrlTitleResolutionHelperInterface::class);
$this->service = new ShortUrlService(
$this->em->reveal(),
$this->urlResolver->reveal(),
$this->urlValidator->reveal(),
$this->titleResolutionHelper->reveal(),
new SimpleShortUrlRelationResolver(),
);
}
@ -93,6 +93,10 @@ class ShortUrlServiceTest extends TestCase
)->willReturn($shortUrl);
$flush = $this->em->flush()->willReturn(null);
$processTitle = $this->titleResolutionHelper->processTitleAndValidateUrl($shortUrlEdit)->willReturn(
$shortUrlEdit,
);
$result = $this->service->updateShortUrl(new ShortUrlIdentifier('abc123'), $shortUrlEdit, $apiKey);
self::assertSame($shortUrl, $result);
@ -102,10 +106,7 @@ class ShortUrlServiceTest extends TestCase
self::assertEquals($shortUrlEdit->longUrl() ?? $originalLongUrl, $shortUrl->getLongUrl());
$findShortUrl->shouldHaveBeenCalled();
$flush->shouldHaveBeenCalled();
$this->urlValidator->validateUrl(
$shortUrlEdit->longUrl(),
$shortUrlEdit->doValidateUrl(),
)->shouldHaveBeenCalledTimes($expectedValidateCalls);
$processTitle->shouldHaveBeenCalledTimes($expectedValidateCalls);
}
public function provideShortUrlEdits(): iterable

View file

@ -16,8 +16,8 @@ use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortCodeHelperInterface;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlTitleResolutionHelperInterface;
use Shlinkio\Shlink\Core\ShortUrl\Resolver\SimpleShortUrlRelationResolver;
use Shlinkio\Shlink\Core\Util\UrlValidatorInterface;
class UrlShortenerTest extends TestCase
{
@ -25,16 +25,13 @@ class UrlShortenerTest extends TestCase
private UrlShortener $urlShortener;
private ObjectProphecy $em;
private ObjectProphecy $urlValidator;
private ObjectProphecy $titleResolutionHelper;
private ObjectProphecy $shortCodeHelper;
public function setUp(): void
{
$this->urlValidator = $this->prophesize(UrlValidatorInterface::class);
$this->urlValidator->validateUrl('http://foobar.com/12345/hello?foo=bar', null)->will(
function (): void {
},
);
$this->titleResolutionHelper = $this->prophesize(ShortUrlTitleResolutionHelperInterface::class);
$this->titleResolutionHelper->processTitleAndValidateUrl(Argument::cetera())->willReturnArgument();
$this->em = $this->prophesize(EntityManagerInterface::class);
$this->em->persist(Argument::any())->will(function ($arguments): void {
@ -56,7 +53,7 @@ class UrlShortenerTest extends TestCase
$this->shortCodeHelper->ensureShortCodeUniqueness(Argument::cetera())->willReturn(true);
$this->urlShortener = new UrlShortener(
$this->urlValidator->reveal(),
$this->titleResolutionHelper->reveal(),
$this->em->reveal(),
new SimpleShortUrlRelationResolver(),
$this->shortCodeHelper->reveal(),
@ -66,11 +63,12 @@ class UrlShortenerTest extends TestCase
/** @test */
public function urlIsProperlyShortened(): void
{
$shortUrl = $this->urlShortener->shorten(
ShortUrlMeta::fromRawData(['longUrl' => 'http://foobar.com/12345/hello?foo=bar']),
);
$longUrl = 'http://foobar.com/12345/hello?foo=bar';
$meta = ShortUrlMeta::fromRawData(['longUrl' => $longUrl]);
$shortUrl = $this->urlShortener->shorten($meta);
self::assertEquals('http://foobar.com/12345/hello?foo=bar', $shortUrl->getLongUrl());
self::assertEquals($longUrl, $shortUrl->getLongUrl());
$this->titleResolutionHelper->processTitleAndValidateUrl($meta)->shouldHaveBeenCalledOnce();
}
/** @test */
@ -101,7 +99,7 @@ class UrlShortenerTest extends TestCase
$findExisting->shouldHaveBeenCalledOnce();
$getRepo->shouldHaveBeenCalledOnce();
$this->em->persist(Argument::cetera())->shouldNotHaveBeenCalled();
$this->urlValidator->validateUrl(Argument::cetera())->shouldNotHaveBeenCalled();
$this->titleResolutionHelper->processTitleAndValidateUrl(Argument::cetera())->shouldNotHaveBeenCalled();
self::assertSame($expected, $result);
}

View file

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\ShortUrl\Helper;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlTitleResolutionHelper;
use Shlinkio\Shlink\Core\Util\UrlValidatorInterface;
class ShortUrlTitleResolutionHelperTest extends TestCase
{
use ProphecyTrait;
private ShortUrlTitleResolutionHelper $helper;
private ObjectProphecy $urlValidator;
protected function setUp(): void
{
$this->urlValidator = $this->prophesize(UrlValidatorInterface::class);
$this->helper = new ShortUrlTitleResolutionHelper($this->urlValidator->reveal());
}
/**
* @test
* @dataProvider provideTitles
*/
public function urlIsProperlyShortened(?string $title, int $validateWithTitleCallsNum, int $validateCallsNum): void
{
$longUrl = 'http://foobar.com/12345/hello?foo=bar';
$this->helper->processTitleAndValidateUrl(
ShortUrlMeta::fromRawData(['longUrl' => $longUrl, 'title' => $title]),
);
$this->urlValidator->validateUrlWithTitle($longUrl, null)->shouldHaveBeenCalledTimes(
$validateWithTitleCallsNum,
);
$this->urlValidator->validateUrl($longUrl, null)->shouldHaveBeenCalledTimes($validateCallsNum);
}
public function provideTitles(): iterable
{
yield 'no title' => [null, 1, 0];
yield 'title' => ['link title', 0, 1];
}
}

View file

@ -9,6 +9,7 @@ use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\RequestOptions;
use Laminas\Diactoros\Response;
use Laminas\Diactoros\Stream;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
@ -76,10 +77,60 @@ class UrlValidatorTest extends TestCase
$request->shouldNotHaveBeenCalled();
}
/**
* @test
* @dataProvider provideDisabledCombinations
*/
public function validateUrlWithTitleReturnsNullWhenRequestFailsAndValidationIsDisabled(
?bool $doValidate,
bool $validateUrl
): void {
$request = $this->httpClient->request(Argument::cetera())->willThrow(ClientException::class);
$this->options->validateUrl = $validateUrl;
$this->options->autoResolveTitles = true;
$result = $this->urlValidator->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', $doValidate);
self::assertNull($result);
$request->shouldHaveBeenCalledOnce();
}
public function provideDisabledCombinations(): iterable
{
yield 'config is disabled and no runtime option is provided' => [null, false];
yield 'config is enabled but runtime option is disabled' => [false, true];
yield 'both config and runtime option are disabled' => [false, false];
}
/** @test */
public function validateUrlWithTitleReturnsNullWhenAutoResolutionIsDisabled(): void
{
$request = $this->httpClient->request(Argument::cetera())->willReturn($this->respWithTitle());
$this->options->autoResolveTitles = false;
$result = $this->urlValidator->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', false);
self::assertNull($result);
$request->shouldNotHaveBeenCalled();
}
/** @test */
public function validateUrlWithTitleResolvesTitleWhenAutoResolutionIsEnabled(): void
{
$request = $this->httpClient->request(Argument::cetera())->willReturn($this->respWithTitle());
$this->options->autoResolveTitles = true;
$result = $this->urlValidator->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', true);
self::assertEquals('Resolved title', $result);
$request->shouldHaveBeenCalledOnce();
}
private function respWithTitle(): Response
{
$body = new Stream('php://temp', 'wr');
$body->write('<title> Resolved title</title>');
return new Response($body);
}
}

View file

@ -12,7 +12,7 @@ use function count;
class ListShortUrlsTest extends ApiTestCase
{
private const SHORT_URL_SHLINK = [
private const SHORT_URL_SHLINK_WITH_TITLE = [
'shortCode' => 'abc123',
'shortUrl' => 'http://doma.in/abc123',
'longUrl' => 'https://shlink.io',
@ -25,6 +25,7 @@ class ListShortUrlsTest extends ApiTestCase
'maxVisits' => null,
],
'domain' => null,
'title' => 'My cool title',
];
private const SHORT_URL_DOCS = [
'shortCode' => 'ghi789',
@ -39,6 +40,7 @@ class ListShortUrlsTest extends ApiTestCase
'maxVisits' => null,
],
'domain' => null,
'title' => null,
];
private const SHORT_URL_CUSTOM_SLUG_AND_DOMAIN = [
'shortCode' => 'custom-with-domain',
@ -53,6 +55,7 @@ class ListShortUrlsTest extends ApiTestCase
'maxVisits' => null,
],
'domain' => 'some-domain.com',
'title' => null,
];
private const SHORT_URL_META = [
'shortCode' => 'def456',
@ -69,6 +72,7 @@ class ListShortUrlsTest extends ApiTestCase
'maxVisits' => null,
],
'domain' => null,
'title' => null,
];
private const SHORT_URL_CUSTOM_SLUG = [
'shortCode' => 'custom',
@ -83,6 +87,7 @@ class ListShortUrlsTest extends ApiTestCase
'maxVisits' => 2,
],
'domain' => null,
'title' => null,
];
private const SHORT_URL_CUSTOM_DOMAIN = [
'shortCode' => 'ghi789',
@ -99,6 +104,7 @@ class ListShortUrlsTest extends ApiTestCase
'maxVisits' => null,
],
'domain' => 'example.com',
'title' => null,
];
/**
@ -122,7 +128,7 @@ class ListShortUrlsTest extends ApiTestCase
public function provideFilteredLists(): iterable
{
yield [[], [
self::SHORT_URL_SHLINK,
self::SHORT_URL_SHLINK_WITH_TITLE,
self::SHORT_URL_DOCS,
self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN,
self::SHORT_URL_META,
@ -130,7 +136,7 @@ class ListShortUrlsTest extends ApiTestCase
self::SHORT_URL_CUSTOM_DOMAIN,
], 'valid_api_key'];
yield [['orderBy' => 'shortCode'], [
self::SHORT_URL_SHLINK,
self::SHORT_URL_SHLINK_WITH_TITLE,
self::SHORT_URL_CUSTOM_SLUG,
self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN,
self::SHORT_URL_META,
@ -143,7 +149,7 @@ class ListShortUrlsTest extends ApiTestCase
self::SHORT_URL_META,
self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN,
self::SHORT_URL_CUSTOM_SLUG,
self::SHORT_URL_SHLINK,
self::SHORT_URL_SHLINK_WITH_TITLE,
], 'valid_api_key'];
yield [['orderBy' => 'shortCode-DESC'], [
self::SHORT_URL_DOCS,
@ -151,7 +157,15 @@ class ListShortUrlsTest extends ApiTestCase
self::SHORT_URL_META,
self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN,
self::SHORT_URL_CUSTOM_SLUG,
self::SHORT_URL_SHLINK,
self::SHORT_URL_SHLINK_WITH_TITLE,
], 'valid_api_key'];
yield [['orderBy' => 'title-DESC'], [
self::SHORT_URL_META,
self::SHORT_URL_CUSTOM_SLUG,
self::SHORT_URL_DOCS,
self::SHORT_URL_CUSTOM_DOMAIN,
self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN,
self::SHORT_URL_SHLINK_WITH_TITLE,
], 'valid_api_key'];
yield [['startDate' => Chronos::parse('2018-12-01')->toAtomString()], [
self::SHORT_URL_META,
@ -159,12 +173,12 @@ class ListShortUrlsTest extends ApiTestCase
self::SHORT_URL_CUSTOM_DOMAIN,
], 'valid_api_key'];
yield [['endDate' => Chronos::parse('2018-12-01')->toAtomString()], [
self::SHORT_URL_SHLINK,
self::SHORT_URL_SHLINK_WITH_TITLE,
self::SHORT_URL_DOCS,
self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN,
], 'valid_api_key'];
yield [['tags' => ['foo']], [
self::SHORT_URL_SHLINK,
self::SHORT_URL_SHLINK_WITH_TITLE,
self::SHORT_URL_META,
self::SHORT_URL_CUSTOM_DOMAIN,
], 'valid_api_key'];
@ -172,17 +186,20 @@ class ListShortUrlsTest extends ApiTestCase
self::SHORT_URL_META,
], 'valid_api_key'];
yield [['tags' => ['foo'], 'endDate' => Chronos::parse('2018-12-01')->toAtomString()], [
self::SHORT_URL_SHLINK,
self::SHORT_URL_SHLINK_WITH_TITLE,
], 'valid_api_key'];
yield [['searchTerm' => 'alejandro'], [
self::SHORT_URL_META,
self::SHORT_URL_CUSTOM_DOMAIN,
], 'valid_api_key'];
yield [['searchTerm' => 'cool'], [
self::SHORT_URL_SHLINK_WITH_TITLE,
], 'valid_api_key'];
yield [['searchTerm' => 'example.com'], [
self::SHORT_URL_CUSTOM_DOMAIN,
], 'valid_api_key'];
yield [[], [
self::SHORT_URL_SHLINK,
self::SHORT_URL_SHLINK_WITH_TITLE,
self::SHORT_URL_META,
self::SHORT_URL_CUSTOM_SLUG,
], 'author_api_key'];

View file

@ -34,6 +34,7 @@ class ShortUrlsFixture extends AbstractFixture implements DependentFixtureInterf
'apiKey' => $authorApiKey,
'longUrl' => 'https://shlink.io',
'tags' => ['foo'],
'title' => 'My cool title',
]), $relationResolver),
'2018-05-01',
);