Created persistence for device long URLs

This commit is contained in:
Alejandro Celaya 2023-01-03 13:45:39 +01:00
parent 5f2f179581
commit 12150f775d
14 changed files with 209 additions and 38 deletions

View file

@ -40,6 +40,7 @@
"mezzio/mezzio-problem-details": "^1.7",
"mezzio/mezzio-swoole": "^4.5",
"mlocati/ip-lib": "^1.18",
"mobiledetect/mobiledetectlib": "^3.74",
"ocramius/proxy-manager": "^2.14",
"pagerfanta/core": "^3.6",
"php-middleware/request-id": "^4.1",

View file

@ -15,6 +15,7 @@ use function class_exists;
use function Shlinkio\Shlink\Config\env;
use function Shlinkio\Shlink\Config\openswooleIsInstalled;
use function Shlinkio\Shlink\Config\runningInRoadRunner;
use function Shlinkio\Shlink\Core\enumValues;
use const PHP_SAPI;
@ -23,7 +24,7 @@ $enableSwoole = PHP_SAPI === 'cli' && openswooleIsInstalled() && ! runningInRoad
return (new ConfigAggregator\ConfigAggregator([
! $isTestEnv
? new EnvVarLoaderProvider('config/params/generated_config.php', Core\Config\EnvVars::values())
? new EnvVarLoaderProvider('config/params/generated_config.php', enumValues(Core\Config\EnvVars::class))
: new ConfigAggregator\ArrayProvider([]),
Mezzio\ConfigProvider::class,
Mezzio\Router\ConfigProvider::class,

View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration;
final class Version20230103105343 extends AbstractMigration
{
private const TABLE_NAME = 'device_long_urls';
public function up(Schema $schema): void
{
$this->skipIf($schema->hasTable(self::TABLE_NAME));
$table = $schema->createTable(self::TABLE_NAME);
$table->addColumn('id', Types::BIGINT, [
'unsigned' => true,
'autoincrement' => true,
'notnull' => true,
]);
$table->setPrimaryKey(['id']);
$table->addColumn('device_type', Types::STRING, ['length' => 255]);
$table->addColumn('long_url', Types::STRING, ['length' => 2048]);
$table->addColumn('short_url_id', Types::BIGINT, [
'unsigned' => true,
'notnull' => true,
]);
$table->addForeignKeyConstraint('short_urls', ['short_url_id'], ['id'], [
'onDelete' => 'CASCADE',
'onUpdate' => 'RESTRICT',
]);
$table->addUniqueIndex(['device_type', 'short_url_id'], 'UQ_device_type_per_short_url');
}
public function down(Schema $schema): void
{
$this->skipIf(! $schema->hasTable(self::TABLE_NAME));
$schema->dropTable(self::TABLE_NAME);
}
public function isTransactional(): bool
{
return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
}
}

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
use Doctrine\ORM\Mapping\Builder\FieldBuilder;
use Doctrine\ORM\Mapping\ClassMetadata;
use Shlinkio\Shlink\Core\Model\DeviceType;
return static function (ClassMetadata $metadata, array $emConfig): void {
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable(determineTableName('device_long_urls', $emConfig));
$builder->createField('id', Types::BIGINT)
->columnName('id')
->makePrimaryKey()
->generatedValue('IDENTITY')
->option('unsigned', true)
->build();
(new FieldBuilder($builder, [
'fieldName' => 'deviceType',
'type' => Types::STRING,
'enumType' => DeviceType::class,
]))->columnName('device_type')
->length(255)
->build();
fieldWithUtf8Charset($builder->createField('longUrl', Types::STRING), $emConfig)
->columnName('long_url')
->length(2048)
->build();
$builder->createManyToOne('shortUrl', ShortUrl\Entity\ShortUrl::class)
->addJoinColumn('short_url_id', 'id', false, false, 'CASCADE')
->build();
};

View file

@ -24,7 +24,7 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
->build();
fieldWithUtf8Charset($builder->createField('longUrl', Types::STRING), $emConfig)
->columnName('original_url')
->columnName('original_url') // Rename to long_url some day? ¯\_(ツ)_/¯
->length(2048)
->build();

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core;
use BackedEnum;
use Cake\Chronos\Chronos;
use Cake\Chronos\ChronosInterface;
use DateTimeInterface;
@ -16,6 +17,7 @@ use PUGX\Shortid\Factory as ShortIdFactory;
use Shlinkio\Shlink\Common\Util\DateRange;
use function date_default_timezone_get;
use function Functional\map;
use function Functional\reduce_left;
use function is_array;
use function print_r;
@ -159,3 +161,19 @@ function toProblemDetailsType(string $errorCode): string
{
return sprintf('https://shlink.io/api/error/%s', $errorCode);
}
/**
* @param class-string<BackedEnum> $enum
* @return string[]
*/
function enumValues(string $enum): array
{
static $cache;
if ($cache === null) {
$cache = [];
}
return $cache[$enum] ?? (
$cache[$enum] = map($enum::cases(), static fn (BackedEnum $type) => (string) $type->value)
);
}

View file

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Config;
use function Functional\map;
use function Shlinkio\Shlink\Config\env;
enum EnvVars: string
@ -77,13 +76,4 @@ enum EnvVars: string
{
return $this->loadFromEnv() !== null;
}
/**
* @return string[]
*/
public static function values(): array
{
static $values;
return $values ?? ($values = map(self::cases(), static fn (EnvVars $envVar) => $envVar->value));
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace Shlinkio\Shlink\Core\Model;
use Detection\MobileDetect;
enum DeviceType: string
{
case ANDROID = 'android';
case IOS = 'ios';
case DESKTOP = 'desktop';
public static function matchFromUserAgent(string $userAgent): ?self
{
$detect = new MobileDetect(null, $userAgent); // @phpstan-ignore-line
return match (true) {
// $detect->is('iOS') && $detect->isTablet() => self::IOS, // TODO To detect iPad only
// $detect->is('iOS') && ! $detect->isTablet() => self::IOS, // TODO To detect iPhone only
// $detect->is('androidOS') && $detect->isTablet() => self::ANDROID, // TODO To detect Android tablets
// $detect->is('androidOS') && ! $detect->isTablet() => self::ANDROID, // TODO To detect Android phones
$detect->is('iOS') => self::IOS, // Detects both iPhone and iPad
$detect->is('androidOS') => self::ANDROID, // Detects both android phones and android tablets
! $detect->isMobile() && ! $detect->isTablet() => self::DESKTOP,
default => null,
};
}
}

View file

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Entity;
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
use Shlinkio\Shlink\Core\Model\DeviceType;
class DeviceLongUrl extends AbstractEntity
{
private function __construct(
public readonly ShortUrl $shortUrl,
public readonly DeviceType $deviceType,
public readonly string $longUrl,
) {
}
}

View file

@ -3,7 +3,6 @@
namespace Shlinkio\Shlink\Core\ShortUrl\Model;
use function Functional\contains;
use function Functional\map;
enum OrderableField: string
{
@ -14,14 +13,6 @@ enum OrderableField: string
case VISITS = 'visits';
case NON_BOT_VISITS = 'nonBotVisits';
/**
* @return string[]
*/
public static function values(): array
{
return map(self::cases(), static fn (OrderableField $field) => $field->value);
}
public static function isBasicField(string $value): bool
{
return contains(

View file

@ -4,15 +4,8 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Model;
use function Functional\map;
enum TagsMode: string
{
case ANY = 'any';
case ALL = 'all';
public static function values(): array
{
return map(self::cases(), static fn (TagsMode $mode) => $mode->value);
}
}

View file

@ -11,6 +11,8 @@ use Shlinkio\Shlink\Common\Validation;
use Shlinkio\Shlink\Core\ShortUrl\Model\OrderableField;
use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
use function Shlinkio\Shlink\Core\enumValues;
class ShortUrlsParamsInputFilter extends InputFilter
{
use Validation\InputFactoryTrait;
@ -46,12 +48,12 @@ class ShortUrlsParamsInputFilter extends InputFilter
$tagsMode = $this->createInput(self::TAGS_MODE, false);
$tagsMode->getValidatorChain()->attach(new InArray([
'haystack' => TagsMode::values(),
'haystack' => enumValues(TagsMode::class),
'strict' => InArray::COMPARE_STRICT,
]));
$this->add($tagsMode);
$this->add($this->createOrderByInput(self::ORDER_BY, OrderableField::values()));
$this->add($this->createOrderByInput(self::ORDER_BY, enumValues(OrderableField::class)));
$this->add($this->createBooleanInput(self::EXCLUDE_MAX_VISITS_REACHED, false));
$this->add($this->createBooleanInput(self::EXCLUDE_PAST_VALID_UNTIL, false));

View file

@ -7,7 +7,6 @@ namespace ShlinkioTest\Shlink\Core\Config;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Config\EnvVars;
use function Functional\map;
use function putenv;
class EnvVarsTest extends TestCase
@ -59,11 +58,4 @@ class EnvVarsTest extends TestCase
yield 'DB_DRIVER without default' => [EnvVars::DB_DRIVER, null, null];
yield 'DB_DRIVER with default' => [EnvVars::DB_DRIVER, 'foobar', 'foobar'];
}
/** @test */
public function allValuesCanBeListed(): void
{
$expected = map(EnvVars::cases(), static fn (EnvVars $envVar) => $envVar->value);
self::assertEquals(EnvVars::values(), $expected);
}
}

View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Functions;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Config\EnvVars;
use Shlinkio\Shlink\Core\Model\DeviceType;
use Shlinkio\Shlink\Core\ShortUrl\Model\OrderableField;
use Shlinkio\Shlink\Core\Visit\Model\VisitType;
use function Functional\map;
use function Shlinkio\Shlink\Core\enumValues;
class FunctionsTest extends TestCase
{
/**
* @test
* @dataProvider provideEnums
*/
public function enumValuesReturnsExpectedValueForEnum(string $enum, array $expectedValues): void
{
self::assertEquals($expectedValues, enumValues($enum));
}
public function provideEnums(): iterable
{
yield EnvVars::class => [EnvVars::class, map(EnvVars::cases(), static fn (EnvVars $envVar) => $envVar->value)];
yield VisitType::class => [
VisitType::class,
map(VisitType::cases(), static fn (VisitType $envVar) => $envVar->value),
];
yield DeviceType::class => [
DeviceType::class,
map(DeviceType::cases(), static fn (DeviceType $envVar) => $envVar->value),
];
yield OrderableField::class => [
OrderableField::class,
map(OrderableField::cases(), static fn (OrderableField $envVar) => $envVar->value),
];
}
}